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                    // P0.5: stamp the shared decision HLC onto every staged row
5057                    // version so live apply and WAL recovery observe the same
5058                    // commit_ts (replicas never allocate a replacement stamp).
5059                    let mut decoded: Vec<crate::memtable::Row> = bincode::deserialize(&rows)
5060                        .map_err(|e| {
5061                            MongrelError::Other(format!(
5062                                "staged txn put rows could not be decoded: {e}"
5063                            ))
5064                        })?;
5065                    for row in &mut decoded {
5066                        // Never stamp HLC::ZERO — that would pin versions under a
5067                        // zero snapshot and hide them from product reads (P0.5).
5068                        if commit_ts != mongreldb_types::hlc::HlcTimestamp::ZERO {
5069                            row.commit_ts = Some(commit_ts);
5070                        }
5071                    }
5072                    let stamped = bincode::serialize(&decoded).map_err(|e| {
5073                        MongrelError::Other(format!("staged txn put rows serialize: {e}"))
5074                    })?;
5075                    Op::Put {
5076                        table_id,
5077                        rows: stamped,
5078                    }
5079                }
5080                StagedTxnWrite::Delete { table_id, row_ids } => Op::Delete {
5081                    table_id,
5082                    row_ids: row_ids.into_iter().map(crate::RowId).collect(),
5083                },
5084            };
5085            records.push(crate::wal::Record::new(Epoch(0), txn_id, op));
5086        }
5087        let epoch = self.epoch.visible().0 + 1;
5088        // The physical component of the decision's commit timestamp goes into
5089        // the durable timestamp ledger, mirroring the ordinary commit path
5090        // (`commit_log::commit_nanos`).
5091        let unix_nanos = commit_ts.physical_micros.saturating_mul(1_000);
5092        records.push(crate::wal::Record::new(
5093            Epoch(0),
5094            txn_id,
5095            Op::CommitTimestamp { unix_nanos },
5096        ));
5097        records.push(crate::wal::Record::new(
5098            Epoch(0),
5099            txn_id,
5100            Op::TxnCommit {
5101                epoch,
5102                added_runs: Vec::new(),
5103            },
5104        ));
5105        let applied = self.apply_replicated_records(&records)?;
5106        if applied && commit_ts != mongreldb_types::hlc::HlcTimestamp::ZERO {
5107            // Ledger + recovery restamp use the shared decision HLC so every
5108            // replica observes the identical commit_ts (P0.5-T4 / P0.5-X1).
5109            self.record_commit_ts(Epoch(epoch), commit_ts);
5110        }
5111        Ok(applied)
5112    }
5113
5114    /// Apply a committed distributed transaction at a shared `commit_ts`
5115    /// (P0.5-T4). Replicas must not allocate a local replacement timestamp —
5116    /// the caller's decision HLC is stamped into the durable ledger and onto
5117    /// every row version recovered from the synthetic WAL records.
5118    pub fn apply_committed_transaction(
5119        &self,
5120        transaction_id: u64,
5121        commit_ts: mongreldb_types::hlc::HlcTimestamp,
5122        operations: &[Vec<u8>],
5123    ) -> Result<bool> {
5124        self.apply_staged_txn_writes(transaction_id, operations, commit_ts)
5125    }
5126
5127    /// Stage 2E (spec sections 10.6, 11.5): apply one committed replicated
5128    /// catalog command and checkpoint the catalog. The record travels as the
5129    /// payload of a replicated `Catalog` command envelope and routes through
5130    /// [`Catalog::apply_command`] — the S1F-001 versioned, idempotent command
5131    /// path (replaying an already-applied `catalog_version` is a no-op).
5132    /// Structural deltas are mirrored into the mounted table set: a created
5133    /// table is mounted, a dropped table unmounted. Deterministic: the record
5134    /// carries every resolved value (ids, epochs, complete images).
5135    pub fn apply_replicated_catalog_command(
5136        &self,
5137        record: &crate::catalog_cmds::CatalogCommandRecord,
5138    ) -> Result<crate::catalog_cmds::CatalogDelta> {
5139        let _operation = self.admit_operation()?;
5140        let _g = self.ddl_lock.lock();
5141        let mut next_catalog = self.catalog.read().clone();
5142        let delta = next_catalog.apply_command(record)?;
5143        if matches!(delta, crate::catalog_cmds::CatalogDelta::NoOp) {
5144            return Ok(delta);
5145        }
5146        // The leader references epochs in structural commands (a table's
5147        // creation/drop epoch). The replica's epoch stream must cover them:
5148        // epochs stay the commit sequencer's authority, so commands never
5149        // allocate one, but the watermark advances to every referenced epoch
5150        // (mirroring how `create_table_with_state` bumps `db_epoch`).
5151        let referenced_epoch = match &delta {
5152            crate::catalog_cmds::CatalogDelta::TableCreated { entry } => Some(entry.created_epoch),
5153            crate::catalog_cmds::CatalogDelta::TableDropped { at_epoch, .. }
5154            | crate::catalog_cmds::CatalogDelta::TableRenamed { at_epoch, .. } => Some(*at_epoch),
5155            _ => None,
5156        };
5157        if let Some(referenced) = referenced_epoch {
5158            self.epoch.advance_recovered(Epoch(referenced));
5159            next_catalog.db_epoch = next_catalog.db_epoch.max(referenced);
5160        }
5161        match &delta {
5162            crate::catalog_cmds::CatalogDelta::TableCreated { entry } => {
5163                // Guard against a repeated mount within one open (the state
5164                // machine's crash-window redispatch is filtered by the NoOp
5165                // arm above; this covers a catalog checkpoint that never
5166                // became durable before a crash).
5167                if !self.tables.read().contains_key(&entry.table_id) {
5168                    self.mount_catalog_entry(entry)?;
5169                }
5170            }
5171            crate::catalog_cmds::CatalogDelta::TableDropped { table_id, .. } => {
5172                self.tables.write().remove(table_id);
5173            }
5174            _ => {}
5175        }
5176        // Durable BEFORE return: the catalog checkpoint is the only local
5177        // durable record of the command (replicated catalog commands do not
5178        // ride the local WAL), and the state machine checkpoints right after.
5179        catalog::write_atomic(&self.root, &next_catalog, self.meta_dek.as_ref())?;
5180        *self.catalog.write() = next_catalog;
5181        Ok(delta)
5182    }
5183
5184    /// Mount a table that a replicated catalog command created (Stage 2E):
5185    /// create the table directory and build the mounted table exactly like
5186    /// the create-table path does, minus the standalone WAL/commit side
5187    /// effects (the command itself is already durable in the raft log).
5188    fn mount_catalog_entry(&self, entry: &crate::catalog::CatalogEntry) -> Result<()> {
5189        let table_relative = Path::new(TABLES_DIR).join(entry.table_id.to_string());
5190        let table_root = Arc::new(
5191            self.durable_root
5192                .create_directory_all_pinned(&table_relative)?,
5193        );
5194        let tdir = table_root.io_path()?;
5195        let ctx = SharedCtx {
5196            root_guard: Some(table_root),
5197            epoch: Arc::clone(&self.epoch),
5198            page_cache: Arc::clone(&self.page_cache),
5199            decoded_cache: Arc::clone(&self.decoded_cache),
5200            snapshots: Arc::clone(&self.snapshots),
5201            kek: self.kek.clone(),
5202            commit_lock: Arc::clone(&self.commit_lock),
5203            shared: Some(crate::engine::SharedWalCtx {
5204                wal: Arc::clone(&self.shared_wal),
5205                group: Arc::clone(&self.group),
5206                poisoned: Arc::clone(&self.poisoned),
5207                txn_ids: Arc::clone(&self.next_txn_id),
5208                change_wake: self.change_wake.clone(),
5209                lifecycle: Arc::clone(&self.lifecycle),
5210                hlc: Arc::clone(&self.hlc),
5211            }),
5212            table_name: Some(entry.name.clone()),
5213            auth: self.table_auth_checker(),
5214            read_only: self.read_only,
5215        };
5216        let table = Table::create_in(&tdir, entry.schema.clone(), entry.table_id, ctx)?;
5217        self.tables
5218            .write()
5219            .insert(entry.table_id, TableHandle::new(table));
5220        Ok(())
5221    }
5222
5223    fn publish_catalog_candidate(
5224        &self,
5225        catalog: Catalog,
5226        epoch: Epoch,
5227        epoch_guard: &mut EpochGuard<'_>,
5228        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
5229    ) -> Result<()> {
5230        self.publish_catalog_candidate_with_prelude(
5231            catalog,
5232            epoch,
5233            epoch_guard,
5234            before_publish,
5235            Vec::new(),
5236        )
5237    }
5238
5239    fn publish_catalog_candidate_with_prelude(
5240        &self,
5241        catalog: Catalog,
5242        epoch: Epoch,
5243        epoch_guard: &mut EpochGuard<'_>,
5244        mut before_publish: Option<&mut dyn FnMut() -> Result<()>>,
5245        prelude: Vec<(u64, crate::wal::Op)>,
5246    ) -> Result<()> {
5247        use crate::wal::DdlOp;
5248
5249        if self.read_only {
5250            return Err(MongrelError::ReadOnlyReplica);
5251        }
5252        if self.poisoned.load(Ordering::Relaxed) {
5253            return Err(MongrelError::Other(
5254                "database poisoned by fsync error".into(),
5255            ));
5256        }
5257        // S1A-004: admit the catalog publish as one core operation.
5258        let _operation = self.admit_operation()?;
5259        if let Some(before_publish) = before_publish.as_mut() {
5260            (**before_publish)()?;
5261        }
5262        if catalog.db_epoch != epoch.0 {
5263            return Err(MongrelError::InvalidArgument(format!(
5264                "catalog epoch {} does not match commit epoch {}",
5265                catalog.db_epoch, epoch.0
5266            )));
5267        }
5268        {
5269            let current = self.catalog.read();
5270            validate_catalog_transition(&current, &catalog)?;
5271        }
5272        validate_recovered_catalog(&catalog)?;
5273        let catalog_json = DdlOp::encode_catalog(&catalog)?;
5274        let txn_id = self.alloc_txn_id()?;
5275        let commit_seq = {
5276            let mut wal = self.shared_wal.lock();
5277            let append: Result<u64> = (|| {
5278                for (table_id, op) in prelude {
5279                    wal.append(txn_id, table_id, op)?;
5280                }
5281                wal.append(
5282                    txn_id,
5283                    WAL_TABLE_ID,
5284                    crate::wal::Op::Ddl(DdlOp::CatalogSnapshot { catalog_json }),
5285                )?;
5286                wal.append_commit(txn_id, epoch, &[])
5287            })();
5288            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
5289        };
5290        let receipt = self.await_durable_commit(txn_id, commit_seq, epoch)?;
5291        let checkpoint = self.checkpoint_catalog_after_durable(catalog);
5292        self.finish_durable_publish(epoch, epoch_guard, &receipt, checkpoint)
5293    }
5294
5295    /// A WAL commit is already durable. Publish the matching catalog in memory
5296    /// even when its checkpoint rewrite fails; recovery can rebuild the file,
5297    /// while the live handle must never continue with pre-commit metadata.
5298    fn checkpoint_catalog_after_durable(&self, catalog: Catalog) -> Result<()> {
5299        let checkpoint = catalog::write_atomic(&self.root, &catalog, self.meta_dek.as_ref());
5300        let version = catalog.security_version;
5301        let principal = self.principal.read().clone();
5302        let principal = if catalog.require_auth
5303            && principal
5304                .as_ref()
5305                .is_some_and(|principal| principal.user_id != 0)
5306        {
5307            principal.as_ref().and_then(|principal| {
5308                Self::resolve_bound_principal_from_catalog(&catalog, principal)
5309            })
5310        } else {
5311            principal
5312        };
5313        *self.catalog.write() = catalog;
5314        self.security_coordinator
5315            .version
5316            .store(version, Ordering::Release);
5317        self.auth_state
5318            .set_require_auth(self.catalog.read().require_auth);
5319        *self.principal.write() = principal.clone();
5320        self.auth_state.set_principal(principal);
5321        checkpoint
5322    }
5323
5324    fn finish_durable_publish(
5325        &self,
5326        epoch: Epoch,
5327        epoch_guard: &mut EpochGuard<'_>,
5328        receipt: &mongreldb_log::CommitReceipt,
5329        post_step: Result<()>,
5330    ) -> Result<()> {
5331        if let Err(error) = self.publish_committed(receipt, epoch) {
5332            // The commit marker is durable but runtime publication failed. The
5333            // epoch guard stays armed so the assigned ticket is abandoned (the
5334            // watermark skips it), and the live handle poisons exactly like any
5335            // other post-durable failure.
5336            self.poisoned.store(true, Ordering::Relaxed);
5337            self.lifecycle.poison();
5338            return Err(MongrelError::DurableCommit {
5339                epoch: epoch.0,
5340                message: error.to_string(),
5341            });
5342        }
5343        epoch_guard.disarm();
5344        match post_step {
5345            Ok(()) => Ok(()),
5346            Err(error) => {
5347                self.poisoned.store(true, Ordering::Relaxed);
5348                self.lifecycle.poison();
5349                Err(MongrelError::DurableCommit {
5350                    epoch: epoch.0,
5351                    message: error.to_string(),
5352                })
5353            }
5354        }
5355    }
5356
5357    /// Advance reader visibility for a committed epoch. Publication is gated on
5358    /// the commit log's receipt (spec §9.4, FND-004): visibility only ever
5359    /// covers commands the commit log acknowledged durable. The
5360    /// `commit.publish.before`/`commit.publish.after` fault hooks bracket the
5361    /// watermark advance (spec §9.6, FND-006).
5362    fn publish_committed(
5363        &self,
5364        receipt: &mongreldb_log::CommitReceipt,
5365        epoch: Epoch,
5366    ) -> Result<()> {
5367        debug_assert_eq!(
5368            receipt.log_position.index, epoch.0,
5369            "commit receipt position must match the published epoch"
5370        );
5371        mongreldb_fault::inject("commit.publish.before").map_err(crate::commit_log::fault_as_io)?;
5372        self.epoch.publish_in_order(epoch);
5373        mongreldb_fault::inject("commit.publish.after").map_err(crate::commit_log::fault_as_io)?;
5374        Ok(())
5375    }
5376
5377    /// Wait for a commit marker to reach stable storage and return the commit
5378    /// log's irrevocable receipt (spec §9.4, FND-004). A failed append/fsync
5379    /// acknowledgement is ambiguous, so poison the live handle and preserve
5380    /// the assigned epoch in a structured unknown-outcome error.
5381    ///
5382    /// Used by the DDL/maintenance commit paths, which do not pre-assign a
5383    /// commit timestamp: the receipt's `commit_ts` is allocated from the
5384    /// node's HLC clock at seal time.
5385    fn await_durable_commit(
5386        &self,
5387        txn_id: u64,
5388        commit_seq: u64,
5389        epoch: Epoch,
5390    ) -> Result<mongreldb_log::CommitReceipt> {
5391        match self
5392            .standalone_commit_log
5393            .seal_transaction(txn_id, epoch, commit_seq, None)
5394        {
5395            Ok(receipt) => {
5396                self.record_commit_ts(epoch, receipt.commit_ts);
5397                Ok(receipt)
5398            }
5399            Err(error) => {
5400                self.poisoned.store(true, Ordering::Relaxed);
5401                self.lifecycle.poison();
5402                Err(MongrelError::CommitOutcomeUnknown {
5403                    epoch: epoch.0,
5404                    message: error.to_string(),
5405                })
5406            }
5407        }
5408    }
5409
5410    /// [`Self::await_durable_commit`] for the transaction commit sequencer,
5411    /// which assigned `commit_ts` under the sequencer lock (S1B-004 step 5,
5412    /// spec §8.2). The receipt carries that exact timestamp, matching the
5413    /// durable `Op::CommitTimestamp` ledger record written at append.
5414    fn await_durable_commit_with_ts(
5415        &self,
5416        txn_id: u64,
5417        commit_seq: u64,
5418        epoch: Epoch,
5419        commit_ts: mongreldb_types::hlc::HlcTimestamp,
5420    ) -> Result<mongreldb_log::CommitReceipt> {
5421        match self.standalone_commit_log.seal_transaction(
5422            txn_id,
5423            epoch,
5424            commit_seq,
5425            Some(commit_ts),
5426        ) {
5427            Ok(receipt) => {
5428                self.record_commit_ts(epoch, receipt.commit_ts);
5429                Ok(receipt)
5430            }
5431            Err(error) => {
5432                self.poisoned.store(true, Ordering::Relaxed);
5433                self.lifecycle.poison();
5434                Err(MongrelError::CommitOutcomeUnknown {
5435                    epoch: epoch.0,
5436                    message: error.to_string(),
5437                })
5438            }
5439        }
5440    }
5441
5442    /// Record one durable commit's receipt timestamp in the per-open ledger
5443    /// (bounded to the newest [`COMMIT_TS_LEDGER_CAP`] epochs). Called by the
5444    /// durability funnels once the commit log has issued the irrevocable
5445    /// receipt.
5446    fn record_commit_ts(&self, epoch: Epoch, commit_ts: mongreldb_types::hlc::HlcTimestamp) {
5447        let mut ledger = self.commit_ts_ledger.lock();
5448        ledger.insert(epoch.0, commit_ts);
5449        while ledger.len() > COMMIT_TS_LEDGER_CAP {
5450            ledger.pop_first();
5451        }
5452    }
5453
5454    /// The commit timestamp of a durable commit, by epoch — the literal
5455    /// write receipt behind the server's read-your-writes token (spec §8.2).
5456    ///
5457    /// Returns `Some` for commits sealed within this open (the exact receipt
5458    /// `HlcTimestamp`) and for commits recovered from the durable
5459    /// `Op::CommitTimestamp` WAL ledger at open; the latter reconstruct the
5460    /// physical component only, with `logical` and `node_tiebreaker` as 0 per
5461    /// the ledger byte format. Only the newest [`COMMIT_TS_LEDGER_CAP`]
5462    /// epochs are retained, and epochs sealed through `CommitLog::propose`
5463    /// (catalog-command proposals) are not recorded here within a live open;
5464    /// both miss shapes return `None`, and callers fall back to a fresh-begin
5465    /// HLC, which the single clock authority orders after every commit it has
5466    /// already issued.
5467    pub fn commit_ts_for_epoch(&self, epoch: Epoch) -> Option<mongreldb_types::hlc::HlcTimestamp> {
5468        self.commit_ts_ledger.lock().get(&epoch.0).copied()
5469    }
5470
5471    /// Newest retained commit epoch whose HLC timestamp is not after `timestamp`.
5472    ///
5473    /// The mapping is bounded by [`COMMIT_TS_LEDGER_CAP`]. Callers needing an
5474    /// older historical point must treat `None` as unavailable, never guess an
5475    /// epoch.
5476    pub fn epoch_at_or_before_commit_ts(
5477        &self,
5478        timestamp: mongreldb_types::hlc::HlcTimestamp,
5479    ) -> Option<Epoch> {
5480        self.commit_ts_ledger
5481            .lock()
5482            .iter()
5483            .rev()
5484            .find_map(|(epoch, commit_ts)| (*commit_ts <= timestamp).then_some(Epoch(*epoch)))
5485    }
5486
5487    fn commit_outcome_unknown(&self, epoch: Epoch, error: impl std::fmt::Display) -> MongrelError {
5488        self.poisoned.store(true, Ordering::Relaxed);
5489        self.lifecycle.poison();
5490        MongrelError::CommitOutcomeUnknown {
5491            epoch: epoch.0,
5492            message: error.to_string(),
5493        }
5494    }
5495
5496    /// Persist a complete validated RLS/masking catalog through the WAL.
5497    pub fn set_security_catalog(&self, security: crate::security::SecurityCatalog) -> Result<()> {
5498        self.set_security_catalog_as_with_epoch(security, None)
5499            .map(|_| ())
5500    }
5501
5502    /// Persist security policy changes on behalf of an explicit request principal.
5503    pub fn set_security_catalog_as(
5504        &self,
5505        security: crate::security::SecurityCatalog,
5506        principal: Option<&crate::auth::Principal>,
5507    ) -> Result<()> {
5508        self.set_security_catalog_as_with_epoch(security, principal)
5509            .map(|_| ())
5510    }
5511
5512    /// Persist security policy changes and return the exact publication epoch.
5513    pub fn set_security_catalog_as_with_epoch(
5514        &self,
5515        security: crate::security::SecurityCatalog,
5516        principal: Option<&crate::auth::Principal>,
5517    ) -> Result<Epoch> {
5518        self.set_security_catalog_as_with_epoch_inner(security, principal, None)
5519    }
5520
5521    /// Persist security policy changes, entering the commit fence immediately
5522    /// before the first WAL record can become visible to recovery.
5523    pub fn set_security_catalog_as_with_epoch_controlled<F>(
5524        &self,
5525        security: crate::security::SecurityCatalog,
5526        principal: Option<&crate::auth::Principal>,
5527        mut before_commit: F,
5528    ) -> Result<Epoch>
5529    where
5530        F: FnMut() -> Result<()>,
5531    {
5532        self.set_security_catalog_as_with_epoch_inner(security, principal, Some(&mut before_commit))
5533    }
5534
5535    fn set_security_catalog_as_with_epoch_inner(
5536        &self,
5537        security: crate::security::SecurityCatalog,
5538        principal: Option<&crate::auth::Principal>,
5539        before_commit: Option<&mut dyn FnMut() -> Result<()>>,
5540    ) -> Result<Epoch> {
5541        use crate::wal::DdlOp;
5542        use std::sync::atomic::Ordering;
5543
5544        // S1F-001: the mutation is a versioned catalog command; its required
5545        // permission is checked against the caller principal first (identical
5546        // to the legacy hardcoded Admin gate).
5547        let command = crate::catalog_cmds::CatalogCommand::SetSecurityCatalog {
5548            security: security.clone(),
5549        };
5550        self.require_for(
5551            principal,
5552            &crate::catalog_cmds::required_permission(&command),
5553        )?;
5554        if self.poisoned.load(Ordering::Relaxed) {
5555            return Err(MongrelError::Other(
5556                "database poisoned by fsync error".into(),
5557            ));
5558        }
5559        // S1A-004: admit the security-catalog replacement as one core
5560        // operation.
5561        let _operation = self.admit_operation()?;
5562        let _ddl = self.ddl_lock.lock();
5563        // DDL serializes first; write-path order after that is security gate ->
5564        // commit lock -> shared WAL.
5565        let _security_write = self.security_write()?;
5566        self.require_for(
5567            principal,
5568            &crate::catalog_cmds::required_permission(&command),
5569        )?;
5570        let mut next_catalog = self.catalog.read().clone();
5571        validate_security_catalog(&next_catalog, &security)?;
5572        let payload = DdlOp::encode_security(&security)?;
5573        let _commit = self.commit_lock.lock();
5574        let epoch = self.epoch.bump_assigned();
5575        let mut epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
5576        let txn_id = self.alloc_txn_id()?;
5577        self.apply_catalog_command_to(&mut next_catalog, command)?;
5578        next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
5579        let commit_seq = {
5580            let mut wal = self.shared_wal.lock();
5581            if let Some(before_commit) = before_commit {
5582                before_commit()?;
5583            }
5584            let append: Result<u64> = (|| {
5585                wal.append(
5586                    txn_id,
5587                    WAL_TABLE_ID,
5588                    crate::wal::Op::Ddl(DdlOp::SetSecurityCatalog {
5589                        security_json: payload,
5590                    }),
5591                )?;
5592                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
5593                wal.append_commit(txn_id, epoch, &[])
5594            })();
5595            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
5596        };
5597        let receipt = self.await_durable_commit(txn_id, commit_seq, epoch)?;
5598        let checkpoint = self.checkpoint_catalog_after_durable(next_catalog);
5599        self.finish_durable_publish(epoch, &mut epoch_guard, &receipt, checkpoint)?;
5600        Ok(epoch)
5601    }
5602
5603    pub fn require_for(
5604        &self,
5605        principal: Option<&crate::auth::Principal>,
5606        permission: &crate::auth::Permission,
5607    ) -> Result<()> {
5608        let Some(principal) = principal else {
5609            return self.require(permission);
5610        };
5611        let resolved;
5612        let principal = if principal.user_id != 0 {
5613            resolved = Self::resolve_bound_principal_from_catalog(&self.catalog.read(), principal)
5614                .ok_or(MongrelError::AuthRequired)?;
5615            &resolved
5616        } else {
5617            principal
5618        };
5619        #[cfg(test)]
5620        TABLE_PERMISSION_DECISIONS.with(|decisions| decisions.set(decisions.get() + 1));
5621        if principal.has_permission(permission) {
5622            Ok(())
5623        } else {
5624            Err(MongrelError::PermissionDenied {
5625                required: permission.clone(),
5626                principal: principal.username.clone(),
5627            })
5628        }
5629    }
5630
5631    /// Recheck the exact operation principal while the caller holds the
5632    /// security gate. This deliberately performs no refresh or nested gate
5633    /// acquisition.
5634    fn require_exact_principal_current(
5635        &self,
5636        principal: Option<&crate::auth::Principal>,
5637        permission: &crate::auth::Permission,
5638    ) -> Result<()> {
5639        let catalog = self.catalog.read();
5640        if !catalog.require_auth {
5641            return Ok(());
5642        }
5643        let supplied = principal.ok_or(MongrelError::AuthRequired)?;
5644        let current = if supplied.user_id == 0 {
5645            supplied.clone()
5646        } else {
5647            Self::resolve_bound_principal_from_catalog(&catalog, supplied)
5648                .ok_or(MongrelError::AuthRequired)?
5649        };
5650        if current.has_permission(permission) {
5651            Ok(())
5652        } else {
5653            Err(MongrelError::PermissionDenied {
5654                required: permission.clone(),
5655                principal: current.username,
5656            })
5657        }
5658    }
5659
5660    pub(crate) fn with_exact_principal_current<T, F>(
5661        &self,
5662        principal: Option<&crate::auth::Principal>,
5663        permission: &crate::auth::Permission,
5664        operation: F,
5665    ) -> Result<T>
5666    where
5667        F: FnOnce() -> Result<T>,
5668    {
5669        let _security = self.security_coordinator.gate.read();
5670        self.require_exact_principal_current(principal, permission)?;
5671        operation()
5672    }
5673
5674    pub fn principal_snapshot(&self) -> Option<crate::auth::Principal> {
5675        self.principal.read().clone()
5676    }
5677
5678    #[cfg(test)]
5679    pub(crate) fn set_cached_principal_for_test(&self, principal: Option<crate::auth::Principal>) {
5680        *self.principal.write() = principal.clone();
5681        self.auth_state.set_principal(principal);
5682    }
5683
5684    pub fn require_columns_for(
5685        &self,
5686        table: &str,
5687        operation: crate::auth::ColumnOperation,
5688        column_ids: &[u16],
5689        principal: Option<&crate::auth::Principal>,
5690    ) -> Result<()> {
5691        if principal.is_none() && !self.auth_state.require_auth() {
5692            return Ok(());
5693        }
5694        let cached = self.principal.read().clone();
5695        let principal = principal.or(cached.as_ref());
5696        let Some(principal) = principal else {
5697            let permission = match operation {
5698                crate::auth::ColumnOperation::Select => crate::auth::Permission::Select {
5699                    table: table.to_string(),
5700                },
5701                crate::auth::ColumnOperation::Insert => crate::auth::Permission::Insert {
5702                    table: table.to_string(),
5703                },
5704                crate::auth::ColumnOperation::Update => crate::auth::Permission::Update {
5705                    table: table.to_string(),
5706                },
5707            };
5708            return self.require(&permission);
5709        };
5710        let catalog = self.catalog.read();
5711        let resolved;
5712        let principal = if principal.user_id != 0 {
5713            resolved = Self::resolve_bound_principal_from_catalog(&catalog, principal)
5714                .ok_or(MongrelError::AuthRequired)?;
5715            &resolved
5716        } else {
5717            principal
5718        };
5719        let schema = &catalog
5720            .live(table)
5721            .ok_or_else(|| MongrelError::NotFound(format!("table {table:?} not found")))?
5722            .schema;
5723        Self::require_columns_for_principal(table, schema, operation, column_ids, principal)
5724    }
5725
5726    fn require_columns_for_principal(
5727        table: &str,
5728        schema: &Schema,
5729        operation: crate::auth::ColumnOperation,
5730        column_ids: &[u16],
5731        principal: &crate::auth::Principal,
5732    ) -> Result<()> {
5733        #[cfg(test)]
5734        WRITE_PERMISSION_DECISIONS.with(|decisions| decisions.set(decisions.get() + 1));
5735        match principal.column_access(table, operation) {
5736            crate::auth::ColumnAccess::All => Ok(()),
5737            crate::auth::ColumnAccess::Columns(allowed) => {
5738                let denied = column_ids.iter().find_map(|column_id| {
5739                    schema
5740                        .columns
5741                        .iter()
5742                        .find(|column| column.id == *column_id)
5743                        .filter(|column| !allowed.contains(&column.name))
5744                });
5745                if denied.is_none() {
5746                    Ok(())
5747                } else {
5748                    Err(MongrelError::PermissionDenied {
5749                        required: match operation {
5750                            crate::auth::ColumnOperation::Select => {
5751                                crate::auth::Permission::SelectColumns {
5752                                    table: table.to_string(),
5753                                    columns: denied
5754                                        .into_iter()
5755                                        .map(|column| column.name.clone())
5756                                        .collect(),
5757                                }
5758                            }
5759                            crate::auth::ColumnOperation::Insert => {
5760                                crate::auth::Permission::InsertColumns {
5761                                    table: table.to_string(),
5762                                    columns: denied
5763                                        .into_iter()
5764                                        .map(|column| column.name.clone())
5765                                        .collect(),
5766                                }
5767                            }
5768                            crate::auth::ColumnOperation::Update => {
5769                                crate::auth::Permission::UpdateColumns {
5770                                    table: table.to_string(),
5771                                    columns: denied
5772                                        .into_iter()
5773                                        .map(|column| column.name.clone())
5774                                        .collect(),
5775                                }
5776                            }
5777                        },
5778                        principal: principal.username.clone(),
5779                    })
5780                }
5781            }
5782            crate::auth::ColumnAccess::Denied => Err(MongrelError::PermissionDenied {
5783                required: match operation {
5784                    crate::auth::ColumnOperation::Select => crate::auth::Permission::Select {
5785                        table: table.to_string(),
5786                    },
5787                    crate::auth::ColumnOperation::Insert => crate::auth::Permission::Insert {
5788                        table: table.to_string(),
5789                    },
5790                    crate::auth::ColumnOperation::Update => crate::auth::Permission::Update {
5791                        table: table.to_string(),
5792                    },
5793                },
5794                principal: principal.username.clone(),
5795            }),
5796        }
5797    }
5798
5799    pub fn select_column_ids_for(
5800        &self,
5801        table: &str,
5802        principal: Option<&crate::auth::Principal>,
5803    ) -> Result<Vec<u16>> {
5804        let catalog = self.catalog.read();
5805        let columns = catalog
5806            .live(table)
5807            .ok_or_else(|| MongrelError::NotFound(format!("table {table:?} not found")))?
5808            .schema
5809            .columns
5810            .iter()
5811            .map(|column| (column.id, column.name.clone()))
5812            .collect::<Vec<_>>();
5813        let principal = self.principal_for_authorized_read(&catalog, principal, false)?;
5814        drop(catalog);
5815        let Some(principal) = principal.as_ref() else {
5816            self.require(&crate::auth::Permission::Select {
5817                table: table.to_string(),
5818            })?;
5819            return Ok(columns.iter().map(|(id, _)| *id).collect());
5820        };
5821        match principal.column_access(table, crate::auth::ColumnOperation::Select) {
5822            crate::auth::ColumnAccess::All => Ok(columns.iter().map(|(id, _)| *id).collect()),
5823            crate::auth::ColumnAccess::Columns(allowed) => Ok(columns
5824                .iter()
5825                .filter(|(_, name)| allowed.contains(name))
5826                .map(|(id, _)| *id)
5827                .collect()),
5828            crate::auth::ColumnAccess::Denied => Err(MongrelError::PermissionDenied {
5829                required: crate::auth::Permission::Select {
5830                    table: table.to_string(),
5831                },
5832                principal: principal.username.clone(),
5833            }),
5834        }
5835    }
5836
5837    pub fn secure_rows_for(
5838        &self,
5839        table: &str,
5840        rows: Vec<crate::memtable::Row>,
5841        principal: Option<&crate::auth::Principal>,
5842    ) -> Result<Vec<crate::memtable::Row>> {
5843        self.secure_rows_for_with_context(table, rows, principal, None)
5844    }
5845
5846    pub fn secure_rows_for_with_context(
5847        &self,
5848        table: &str,
5849        rows: Vec<crate::memtable::Row>,
5850        principal: Option<&crate::auth::Principal>,
5851        context: Option<&crate::query::AiExecutionContext>,
5852    ) -> Result<Vec<crate::memtable::Row>> {
5853        let (security, principal) = {
5854            let catalog = self.catalog.read();
5855            (
5856                catalog.security.clone(),
5857                self.principal_for_authorized_read(&catalog, principal, false)?,
5858            )
5859        };
5860        if !security.table_has_security(table) {
5861            return Ok(rows);
5862        }
5863        let principal = principal.as_ref().ok_or(MongrelError::AuthRequired)?;
5864        let mut output = Vec::new();
5865        for mut row in rows {
5866            if let Some(context) = context {
5867                context.consume(1)?;
5868            }
5869            if security.row_allowed(
5870                table,
5871                crate::security::PolicyCommand::Select,
5872                &row,
5873                principal,
5874                false,
5875            ) {
5876                security.apply_masks(table, &mut row, principal);
5877                output.push(row);
5878            }
5879        }
5880        Ok(output)
5881    }
5882
5883    /// Apply column masks to already RLS-authorized scored hits without a
5884    /// second row gather or policy evaluation.
5885    pub fn mask_search_hits_for(
5886        &self,
5887        table: &str,
5888        hits: &mut [crate::query::SearchHit],
5889        principal: Option<&crate::auth::Principal>,
5890    ) -> Result<()> {
5891        let (security, principal) = {
5892            let catalog = self.catalog.read();
5893            (
5894                catalog.security.clone(),
5895                self.principal_for_authorized_read(&catalog, principal, false)?,
5896            )
5897        };
5898        if !security.table_has_security(table) {
5899            return Ok(());
5900        }
5901        let principal = principal.as_ref().ok_or(MongrelError::AuthRequired)?;
5902        for hit in hits {
5903            security.apply_masks_to_cells(table, &mut hit.cells, principal);
5904        }
5905        Ok(())
5906    }
5907
5908    /// Apply masks to rows already admitted by candidate-aware RLS.
5909    pub fn mask_rows_for(
5910        &self,
5911        table: &str,
5912        rows: &mut [crate::memtable::Row],
5913        principal: Option<&crate::auth::Principal>,
5914    ) -> Result<()> {
5915        let (security, principal) = {
5916            let catalog = self.catalog.read();
5917            (
5918                catalog.security.clone(),
5919                self.principal_for_authorized_read(&catalog, principal, false)?,
5920            )
5921        };
5922        if !security.table_has_security(table) {
5923            return Ok(());
5924        }
5925        let principal = principal.as_ref().ok_or(MongrelError::AuthRequired)?;
5926        for row in rows {
5927            security.apply_masks(table, row, principal);
5928        }
5929        Ok(())
5930    }
5931
5932    /// Row IDs allowed to enter scored ranking. `None` means no RLS filter.
5933    pub fn authorized_candidate_ids_for(
5934        &self,
5935        table: &str,
5936        principal: Option<&crate::auth::Principal>,
5937    ) -> Result<Option<std::collections::HashSet<RowId>>> {
5938        Ok(self
5939            .authorized_read_snapshot(table, principal)?
5940            .allowed_row_ids)
5941    }
5942
5943    fn allowed_row_ids_locked(
5944        &self,
5945        table_name: &str,
5946        table: &Table,
5947        table_snapshot: Snapshot,
5948        security_state: (&crate::security::SecurityCatalog, u64),
5949        principal: Option<&crate::auth::Principal>,
5950        context: Option<&crate::query::AiExecutionContext>,
5951    ) -> Result<Option<Arc<HashSet<RowId>>>> {
5952        let (security, security_version) = security_state;
5953        if !security.rls_enabled(table_name) {
5954            return Ok(None);
5955        }
5956        let authorization_started = std::time::Instant::now();
5957        let principal = principal.ok_or(MongrelError::AuthRequired)?;
5958        let mut roles = principal.roles.clone();
5959        roles.sort_unstable();
5960        let principal_key = format!(
5961            "{}:{}:{}:{}:{roles:?}",
5962            principal.user_id, principal.created_epoch, principal.username, principal.is_admin
5963        );
5964        let cache_key = (
5965            table_name.to_string(),
5966            table.data_generation(),
5967            security_version,
5968            principal_key,
5969        );
5970        if let Some(allowed) = self.rls_cache.lock().get(&cache_key) {
5971            crate::trace::QueryTrace::record(|trace| {
5972                trace.rls_cache_hit = true;
5973                trace.authorization_nanos = trace
5974                    .authorization_nanos
5975                    .saturating_add(authorization_started.elapsed().as_nanos() as u64);
5976            });
5977            return Ok(Some(allowed));
5978        }
5979        if let Some(context) = context {
5980            context.checkpoint()?;
5981        }
5982        // ponytail: full RLS universe scan; replace with policy-column candidate checks if RLS search throughput matters.
5983        let started = std::time::Instant::now();
5984        let rows = table.visible_rows(table_snapshot)?;
5985        let rows_evaluated = rows.len() as u64;
5986        let mut allowed = HashSet::new();
5987        for chunk in rows.chunks(256) {
5988            if let Some(context) = context {
5989                context.consume(chunk.len())?;
5990            }
5991            allowed.extend(chunk.iter().filter_map(|row| {
5992                security
5993                    .row_allowed(
5994                        table_name,
5995                        crate::security::PolicyCommand::Select,
5996                        row,
5997                        principal,
5998                        false,
5999                    )
6000                    .then_some(row.row_id)
6001            }));
6002        }
6003        let allowed = Arc::new(allowed);
6004        let mut cache = self.rls_cache.lock();
6005        cache.build_nanos = cache
6006            .build_nanos
6007            .saturating_add(started.elapsed().as_nanos() as u64);
6008        cache.rows_evaluated = cache.rows_evaluated.saturating_add(rows_evaluated);
6009        cache.insert(cache_key, Arc::clone(&allowed));
6010        crate::trace::QueryTrace::record(|trace| {
6011            trace.rls_rows_evaluated = trace
6012                .rls_rows_evaluated
6013                .saturating_add(rows_evaluated as usize);
6014            trace.authorization_nanos = trace
6015                .authorization_nanos
6016                .saturating_add(authorization_started.elapsed().as_nanos() as u64);
6017        });
6018        Ok(Some(allowed))
6019    }
6020
6021    fn principal_for_authorized_read(
6022        &self,
6023        catalog: &Catalog,
6024        principal: Option<&crate::auth::Principal>,
6025        catalog_bound: bool,
6026    ) -> Result<Option<crate::auth::Principal>> {
6027        let principal = principal.cloned().or_else(|| self.principal.read().clone());
6028        let Some(principal) = principal else {
6029            return Ok(None);
6030        };
6031        if catalog_bound || principal.user_id != 0 {
6032            return Self::resolve_bound_principal_from_catalog(catalog, &principal)
6033                .map(Some)
6034                .ok_or(MongrelError::AuthRequired);
6035        }
6036        Ok(Some(principal))
6037    }
6038
6039    /// Run authorization, candidate generation, ranking, and materialization
6040    /// while holding one table generation. Security changes cause a bounded
6041    /// retry before any result is published.
6042    pub fn with_authorized_read<T, F>(
6043        &self,
6044        table_name: &str,
6045        principal: Option<&crate::auth::Principal>,
6046        catalog_bound: bool,
6047        read: F,
6048    ) -> Result<T>
6049    where
6050        F: FnMut(
6051            &mut Table,
6052            Snapshot,
6053            Option<&HashSet<RowId>>,
6054            Option<&crate::auth::Principal>,
6055        ) -> Result<T>,
6056    {
6057        self.with_authorized_read_context(
6058            table_name,
6059            principal,
6060            catalog_bound,
6061            None,
6062            None,
6063            None,
6064            read,
6065        )
6066    }
6067
6068    #[allow(clippy::too_many_arguments)]
6069    pub fn with_authorized_read_context<T, F>(
6070        &self,
6071        table_name: &str,
6072        principal: Option<&crate::auth::Principal>,
6073        catalog_bound: bool,
6074        authorization: Option<&ReadAuthorization>,
6075        context: Option<&crate::query::AiExecutionContext>,
6076        snapshot_override: Option<Snapshot>,
6077        read: F,
6078    ) -> Result<T>
6079    where
6080        F: FnMut(
6081            &mut Table,
6082            Snapshot,
6083            Option<&HashSet<RowId>>,
6084            Option<&crate::auth::Principal>,
6085        ) -> Result<T>,
6086    {
6087        self.with_authorized_read_context_stamped(
6088            table_name,
6089            principal,
6090            catalog_bound,
6091            authorization,
6092            context,
6093            snapshot_override,
6094            read,
6095        )
6096        .map(|(result, _)| result)
6097    }
6098
6099    #[allow(clippy::too_many_arguments)]
6100    pub fn with_authorized_read_context_stamped<T, F>(
6101        &self,
6102        table_name: &str,
6103        principal: Option<&crate::auth::Principal>,
6104        catalog_bound: bool,
6105        authorization: Option<&ReadAuthorization>,
6106        context: Option<&crate::query::AiExecutionContext>,
6107        snapshot_override: Option<Snapshot>,
6108        mut read: F,
6109    ) -> Result<(T, AuthorizedReadStamp)>
6110    where
6111        F: FnMut(
6112            &mut Table,
6113            Snapshot,
6114            Option<&HashSet<RowId>>,
6115            Option<&crate::auth::Principal>,
6116        ) -> Result<T>,
6117    {
6118        if principal.is_none() && self.principal.read().is_some() {
6119            self.refresh_principal()?;
6120        }
6121        const RETRIES: usize = 3;
6122        let handle = self.table(table_name)?;
6123        for attempt in 0..RETRIES {
6124            crate::trace::QueryTrace::record(|trace| {
6125                trace.authorization_retries = attempt;
6126            });
6127            let (security, security_version, effective_principal) = {
6128                let catalog = self.catalog.read();
6129                (
6130                    catalog.security.clone(),
6131                    catalog.security_version,
6132                    self.principal_for_authorized_read(&catalog, principal, catalog_bound)?,
6133                )
6134            };
6135            if let Some(authorization) = authorization {
6136                for permission in &authorization.permissions {
6137                    self.require_for(effective_principal.as_ref(), permission)?;
6138                }
6139                self.require_columns_for(
6140                    table_name,
6141                    authorization.operation,
6142                    &authorization.columns,
6143                    effective_principal.as_ref(),
6144                )?;
6145            }
6146            let result = {
6147                let mut table = lock_table_with_context(&handle, context)?;
6148                let snapshot = snapshot_override.unwrap_or_else(|| table.snapshot());
6149                let allowed = self.allowed_row_ids_locked(
6150                    table_name,
6151                    &table,
6152                    snapshot,
6153                    (&security, security_version),
6154                    effective_principal.as_ref(),
6155                    context,
6156                )?;
6157                let stamp = AuthorizedReadStamp {
6158                    table_id: table.table_id(),
6159                    schema_id: table.schema().schema_id,
6160                    data_generation: table.data_generation(),
6161                    security_version,
6162                    snapshot,
6163                };
6164                let result = read(
6165                    &mut table,
6166                    snapshot,
6167                    allowed.as_deref(),
6168                    effective_principal.as_ref(),
6169                )?;
6170                (result, stamp)
6171            };
6172            if let Some(context) = context {
6173                context.checkpoint()?;
6174            }
6175            if self.catalog.read().security_version == security_version {
6176                return Ok(result);
6177            }
6178            if attempt + 1 == RETRIES {
6179                return Err(MongrelError::Conflict(
6180                    "security policy changed during scored read".into(),
6181                ));
6182            }
6183        }
6184        Err(MongrelError::Conflict(
6185            "authorization retry loop exhausted".into(),
6186        ))
6187    }
6188
6189    fn with_authorized_aggregate_table<T, F>(
6190        &self,
6191        table_name: &str,
6192        columns: &[u16],
6193        principal: Option<&crate::auth::Principal>,
6194        catalog_bound: bool,
6195        allow_table_security: bool,
6196        mut aggregate: F,
6197    ) -> Result<T>
6198    where
6199        F: FnMut(
6200            &mut Table,
6201            Option<&crate::security::CandidateAuthorization<'_>>,
6202            Option<&crate::auth::Principal>,
6203            u64,
6204        ) -> Result<T>,
6205    {
6206        if principal.is_none() && self.principal.read().is_some() {
6207            self.refresh_principal()?;
6208        }
6209        const RETRIES: usize = 3;
6210        let handle = self.table(table_name)?;
6211        for attempt in 0..RETRIES {
6212            let (security, security_version, effective_principal) = {
6213                let catalog = self.catalog.read();
6214                (
6215                    catalog.security.clone(),
6216                    catalog.security_version,
6217                    self.principal_for_authorized_read(&catalog, principal, catalog_bound)?,
6218                )
6219            };
6220            self.require_columns_for(
6221                table_name,
6222                crate::auth::ColumnOperation::Select,
6223                columns,
6224                effective_principal.as_ref(),
6225            )?;
6226            if !allow_table_security && security.table_has_security(table_name) {
6227                return Err(MongrelError::InvalidArgument(
6228                    "incremental aggregate is unsupported while RLS or column masks are active"
6229                        .into(),
6230                ));
6231            }
6232            let result = {
6233                let mut table = handle.lock();
6234                let authorization = if security.rls_enabled(table_name) {
6235                    Some(crate::security::CandidateAuthorization {
6236                        table: table_name,
6237                        security: &security,
6238                        principal: effective_principal
6239                            .as_ref()
6240                            .ok_or(MongrelError::AuthRequired)?,
6241                    })
6242                } else {
6243                    None
6244                };
6245                aggregate(
6246                    &mut table,
6247                    authorization.as_ref(),
6248                    effective_principal.as_ref(),
6249                    security_version,
6250                )?
6251            };
6252            if self.catalog.read().security_version == security_version {
6253                return Ok(result);
6254            }
6255            if attempt + 1 == RETRIES {
6256                return Err(MongrelError::Conflict(
6257                    "security policy changed during aggregate read".into(),
6258                ));
6259            }
6260        }
6261        Err(MongrelError::Conflict(
6262            "aggregate authorization retry loop exhausted".into(),
6263        ))
6264    }
6265
6266    /// Scored-read authorization that evaluates RLS only for approximate
6267    /// candidates. This avoids a full-table policy scan on cache misses while
6268    /// preserving one table generation and security-version retry.
6269    pub fn with_authorized_scored_read_context<T, F>(
6270        &self,
6271        table_name: &str,
6272        principal: Option<&crate::auth::Principal>,
6273        catalog_bound: bool,
6274        authorization: Option<&ReadAuthorization>,
6275        context: Option<&crate::query::AiExecutionContext>,
6276        mut read: F,
6277    ) -> Result<T>
6278    where
6279        F: FnMut(
6280            &mut Table,
6281            Snapshot,
6282            Option<&crate::security::CandidateAuthorization<'_>>,
6283            Option<&crate::auth::Principal>,
6284        ) -> Result<T>,
6285    {
6286        self.with_authorized_scored_read_context_at(
6287            table_name,
6288            principal,
6289            catalog_bound,
6290            authorization,
6291            context,
6292            None,
6293            |table, snapshot, authorization, principal| {
6294                let mut table = table.clone();
6295                read(&mut table, snapshot, authorization, principal)
6296            },
6297        )
6298    }
6299
6300    #[allow(clippy::too_many_arguments)]
6301    pub fn with_authorized_scored_read_context_at<T, F>(
6302        &self,
6303        table_name: &str,
6304        principal: Option<&crate::auth::Principal>,
6305        catalog_bound: bool,
6306        authorization: Option<&ReadAuthorization>,
6307        context: Option<&crate::query::AiExecutionContext>,
6308        snapshot_override: Option<Snapshot>,
6309        read: F,
6310    ) -> Result<T>
6311    where
6312        F: FnMut(
6313            &Table,
6314            Snapshot,
6315            Option<&crate::security::CandidateAuthorization<'_>>,
6316            Option<&crate::auth::Principal>,
6317        ) -> Result<T>,
6318    {
6319        self.with_authorized_scored_read_context_at_stamped(
6320            table_name,
6321            principal,
6322            catalog_bound,
6323            authorization,
6324            context,
6325            snapshot_override,
6326            read,
6327        )
6328        .map(|(result, _)| result)
6329    }
6330
6331    #[allow(clippy::too_many_arguments)]
6332    pub fn with_authorized_scored_read_context_at_stamped<T, F>(
6333        &self,
6334        table_name: &str,
6335        principal: Option<&crate::auth::Principal>,
6336        catalog_bound: bool,
6337        authorization: Option<&ReadAuthorization>,
6338        context: Option<&crate::query::AiExecutionContext>,
6339        snapshot_override: Option<Snapshot>,
6340        mut read: F,
6341    ) -> Result<(T, AuthorizedReadStamp)>
6342    where
6343        F: FnMut(
6344            &Table,
6345            Snapshot,
6346            Option<&crate::security::CandidateAuthorization<'_>>,
6347            Option<&crate::auth::Principal>,
6348        ) -> Result<T>,
6349    {
6350        if principal.is_none() && self.principal.read().is_some() {
6351            self.refresh_principal()?;
6352        }
6353        const RETRIES: usize = 3;
6354        let handle = self.table(table_name)?;
6355        for attempt in 0..RETRIES {
6356            if let Some(context) = context {
6357                context.checkpoint()?;
6358            }
6359            crate::trace::QueryTrace::record(|trace| {
6360                trace.authorization_retries = attempt;
6361            });
6362            let (security, security_version, effective_principal) = {
6363                let catalog = self.catalog.read();
6364                (
6365                    catalog.security.clone(),
6366                    catalog.security_version,
6367                    self.principal_for_authorized_read(&catalog, principal, catalog_bound)?,
6368                )
6369            };
6370            if let Some(authorization) = authorization {
6371                for permission in &authorization.permissions {
6372                    self.require_for(effective_principal.as_ref(), permission)?;
6373                }
6374                self.require_columns_for(
6375                    table_name,
6376                    authorization.operation,
6377                    &authorization.columns,
6378                    effective_principal.as_ref(),
6379                )?;
6380            }
6381            let result = {
6382                let (table, snapshot, _snapshot_guard, _run_pins) =
6383                    self.scored_read_generation(&handle, context, snapshot_override)?;
6384                let candidate_authorization = if security.rls_enabled(table_name) {
6385                    Some(crate::security::CandidateAuthorization {
6386                        table: table_name,
6387                        security: &security,
6388                        principal: effective_principal
6389                            .as_ref()
6390                            .ok_or(MongrelError::AuthRequired)?,
6391                    })
6392                } else {
6393                    None
6394                };
6395                let stamp = AuthorizedReadStamp {
6396                    table_id: table.table_id(),
6397                    schema_id: table.schema().schema_id,
6398                    data_generation: table.data_generation(),
6399                    security_version,
6400                    snapshot,
6401                };
6402                let result = read(
6403                    table.as_ref(),
6404                    snapshot,
6405                    candidate_authorization.as_ref(),
6406                    effective_principal.as_ref(),
6407                )?;
6408                (result, stamp)
6409            };
6410            if let Some(context) = context {
6411                context.checkpoint()?;
6412            }
6413            if self.catalog.read().security_version == security_version {
6414                return Ok(result);
6415            }
6416            if attempt + 1 == RETRIES {
6417                return Err(MongrelError::Conflict(
6418                    "security policy changed during scored read".into(),
6419                ));
6420            }
6421        }
6422        Err(MongrelError::Conflict(
6423            "scored-read authorization retry loop exhausted".into(),
6424        ))
6425    }
6426
6427    fn scored_read_generation(
6428        &self,
6429        handle: &TableHandle,
6430        context: Option<&crate::query::AiExecutionContext>,
6431        snapshot_override: Option<Snapshot>,
6432    ) -> Result<(
6433        Arc<TableReadGeneration>,
6434        Snapshot,
6435        crate::retention::OwnedSnapshotGuard,
6436        RunPins,
6437    )> {
6438        let mut table = if let Some(context) = context {
6439            loop {
6440                context.checkpoint()?;
6441                let wait = context
6442                    .remaining_duration()
6443                    .unwrap_or(std::time::Duration::from_millis(5))
6444                    .min(std::time::Duration::from_millis(5));
6445                if let Some(table) = handle.try_lock_for(wait) {
6446                    break table;
6447                }
6448            }
6449        } else {
6450            handle.lock()
6451        };
6452        let (snapshot, snapshot_guard) = if let Some(snapshot) = snapshot_override {
6453            self.snapshot_at_owned(snapshot.epoch)?
6454        } else {
6455            let snapshot = table.snapshot();
6456            let guard = self.snapshots.register_owned(snapshot.epoch);
6457            (snapshot, guard)
6458        };
6459        let table_id = table.table_id();
6460        let run_keys: Vec<_> = table
6461            .active_run_ids()
6462            .map(|run_id| (table_id, run_id))
6463            .collect();
6464        let generation = handle
6465            .generation_metrics
6466            .activate(table.clone_read_generation()?);
6467        let run_pins = self.pin_runs(&run_keys);
6468        Ok((generation, snapshot, snapshot_guard, run_pins))
6469    }
6470
6471    fn pin_runs(&self, runs: &[(u64, u128)]) -> RunPins {
6472        let mut pins = self.backup_pins.lock();
6473        for run in runs {
6474            *pins.entry(*run).or_insert(0) += 1;
6475        }
6476        drop(pins);
6477        RunPins {
6478            pins: Arc::clone(&self.backup_pins),
6479            runs: runs.to_vec(),
6480        }
6481    }
6482
6483    /// Execute a native conjunctive read with the database principal's row
6484    /// policy, column grants, and masks applied. Raw [`Table`] methods remain
6485    /// policy-unaware; language bindings must use this boundary for reads.
6486    pub fn query_for_current_principal(
6487        &self,
6488        table_name: &str,
6489        query: &crate::query::Query,
6490        projection: Option<&[u16]>,
6491    ) -> Result<Vec<crate::memtable::Row>> {
6492        let condition_columns = crate::query::condition_columns(&query.conditions);
6493        let catalog_bound = self
6494            .principal
6495            .read()
6496            .as_ref()
6497            .is_some_and(|principal| principal.user_id != 0);
6498        self.with_authorized_read(
6499            table_name,
6500            None,
6501            catalog_bound,
6502            |table, snapshot, allowed, principal| {
6503                let allowed_columns = self.select_column_ids_for(table_name, principal)?;
6504                self.require_columns_for(
6505                    table_name,
6506                    crate::auth::ColumnOperation::Select,
6507                    &condition_columns,
6508                    principal,
6509                )?;
6510                if let Some(projection) = projection {
6511                    self.require_columns_for(
6512                        table_name,
6513                        crate::auth::ColumnOperation::Select,
6514                        projection,
6515                        principal,
6516                    )?;
6517                }
6518                let mut rows = table.query_at_with_allowed(query, snapshot, allowed)?;
6519                let projection =
6520                    projection.map(|columns| columns.iter().copied().collect::<HashSet<_>>());
6521                for row in &mut rows {
6522                    row.columns.retain(|column, _| {
6523                        allowed_columns.contains(column)
6524                            && projection
6525                                .as_ref()
6526                                .is_none_or(|projection| projection.contains(column))
6527                    });
6528                }
6529                self.secure_rows_for(table_name, rows, principal)
6530            },
6531        )
6532    }
6533
6534    /// Execute a secured native read with cooperative cancellation across
6535    /// authorization, candidate generation, materialization, masking, and
6536    /// projection.
6537    pub fn query_for_current_principal_controlled(
6538        &self,
6539        table_name: &str,
6540        query: &crate::query::Query,
6541        projection: Option<&[u16]>,
6542        control: &crate::ExecutionControl,
6543    ) -> Result<Vec<crate::memtable::Row>> {
6544        let catalog_bound = self
6545            .principal
6546            .read()
6547            .as_ref()
6548            .is_some_and(|principal| principal.user_id != 0);
6549        self.query_for_principal_controlled(
6550            table_name,
6551            query,
6552            projection,
6553            None,
6554            catalog_bound,
6555            control,
6556        )
6557    }
6558
6559    /// Execute a secured native read as an explicitly validated principal.
6560    ///
6561    /// Protocol adapters use this after resolving a session-bound identity.
6562    pub fn query_as_principal_controlled(
6563        &self,
6564        table_name: &str,
6565        query: &crate::query::Query,
6566        projection: Option<&[u16]>,
6567        principal: Option<&crate::auth::Principal>,
6568        control: &crate::ExecutionControl,
6569    ) -> Result<Vec<crate::memtable::Row>> {
6570        let catalog_bound = principal.is_some_and(|principal| principal.user_id != 0);
6571        self.query_for_principal_controlled(
6572            table_name,
6573            query,
6574            projection,
6575            principal,
6576            catalog_bound,
6577            control,
6578        )
6579    }
6580
6581    fn query_for_principal_controlled(
6582        &self,
6583        table_name: &str,
6584        query: &crate::query::Query,
6585        projection: Option<&[u16]>,
6586        principal: Option<&crate::auth::Principal>,
6587        catalog_bound: bool,
6588        control: &crate::ExecutionControl,
6589    ) -> Result<Vec<crate::memtable::Row>> {
6590        control.checkpoint()?;
6591        let context = crate::query::AiExecutionContext::with_control(
6592            control.clone(),
6593            usize::MAX,
6594            crate::query::MAX_FUSED_CANDIDATES,
6595        );
6596        let condition_columns = crate::query::condition_columns(&query.conditions);
6597        self.with_authorized_read_context(
6598            table_name,
6599            principal,
6600            catalog_bound,
6601            None,
6602            Some(&context),
6603            None,
6604            |table, snapshot, allowed, principal| {
6605                control.checkpoint()?;
6606                let allowed_columns = self.select_column_ids_for(table_name, principal)?;
6607                self.require_columns_for(
6608                    table_name,
6609                    crate::auth::ColumnOperation::Select,
6610                    &condition_columns,
6611                    principal,
6612                )?;
6613                if let Some(projection) = projection {
6614                    self.require_columns_for(
6615                        table_name,
6616                        crate::auth::ColumnOperation::Select,
6617                        projection,
6618                        principal,
6619                    )?;
6620                }
6621                let rows =
6622                    table.query_at_with_allowed_controlled(query, snapshot, allowed, control)?;
6623                let projection =
6624                    projection.map(|columns| columns.iter().copied().collect::<HashSet<_>>());
6625                let mut projected = Vec::with_capacity(rows.len());
6626                for (index, mut row) in rows.into_iter().enumerate() {
6627                    if index & 255 == 0 {
6628                        control.checkpoint()?;
6629                    }
6630                    row.columns.retain(|column, _| {
6631                        allowed_columns.contains(column)
6632                            && projection
6633                                .as_ref()
6634                                .is_none_or(|projection| projection.contains(column))
6635                    });
6636                    projected.push(row);
6637                }
6638                self.secure_rows_for_with_context(table_name, projected, principal, Some(&context))
6639            },
6640        )
6641    }
6642
6643    /// Reservoir aggregate with column grants, RLS, masks, and security-version
6644    /// retry applied at the database boundary.
6645    pub fn approx_aggregate_for_current_principal(
6646        &self,
6647        table_name: &str,
6648        conditions: &[crate::query::Condition],
6649        column: Option<u16>,
6650        agg: crate::engine::ApproxAgg,
6651        z: f64,
6652    ) -> Result<Option<crate::engine::ApproxResult>> {
6653        if !z.is_finite() || z <= 0.0 {
6654            return Err(MongrelError::InvalidArgument(
6655                "z must be finite and > 0".into(),
6656            ));
6657        }
6658        let mut columns = crate::query::condition_columns(conditions);
6659        columns.extend(column);
6660        columns.sort_unstable();
6661        columns.dedup();
6662        self.with_authorized_aggregate_table(
6663            table_name,
6664            &columns,
6665            None,
6666            true,
6667            true,
6668            |table, authorization, _, _| {
6669                table.approx_aggregate_with_candidate_authorization(
6670                    conditions,
6671                    column,
6672                    agg,
6673                    z,
6674                    authorization,
6675                )
6676            },
6677        )
6678    }
6679
6680    /// Incremental aggregate over an append-only table. Active RLS or masks are
6681    /// rejected because the table-global delta cache cannot safely represent a
6682    /// secured row universe.
6683    pub fn incremental_aggregate_for_current_principal(
6684        &self,
6685        table_name: &str,
6686        conditions: &[crate::query::Condition],
6687        column: Option<u16>,
6688        agg: crate::engine::NativeAgg,
6689    ) -> Result<crate::engine::IncrementalAggResult> {
6690        self.incremental_aggregate_for_principal(table_name, conditions, column, agg, None, true)
6691    }
6692
6693    /// Incremental aggregate using an explicit request principal. A
6694    /// catalog-bound principal is re-resolved on every retry so live grants,
6695    /// revocations, RLS, and masks cannot reuse a stale cache entry.
6696    pub fn incremental_aggregate_for_principal(
6697        &self,
6698        table_name: &str,
6699        conditions: &[crate::query::Condition],
6700        column: Option<u16>,
6701        agg: crate::engine::NativeAgg,
6702        principal: Option<&crate::auth::Principal>,
6703        catalog_bound: bool,
6704    ) -> Result<crate::engine::IncrementalAggResult> {
6705        let mut columns = crate::query::condition_columns(conditions);
6706        columns.extend(column);
6707        columns.sort_unstable();
6708        columns.dedup();
6709        self.with_authorized_aggregate_table(
6710            table_name,
6711            &columns,
6712            principal,
6713            catalog_bound,
6714            false,
6715            |table, _, principal, security_version| {
6716                let cache_key = incremental_aggregate_cache_key(
6717                    table_name,
6718                    conditions,
6719                    column,
6720                    agg,
6721                    principal,
6722                    security_version,
6723                );
6724                table.aggregate_incremental(cache_key, conditions, column, agg)
6725            },
6726        )
6727    }
6728
6729    /// Read one row with the database principal's row policy, column grants,
6730    /// and masks applied.
6731    pub fn get_for_current_principal(
6732        &self,
6733        table_name: &str,
6734        row_id: RowId,
6735    ) -> Result<Option<crate::memtable::Row>> {
6736        self.with_authorized_read(
6737            table_name,
6738            None,
6739            true,
6740            |table, snapshot, allowed, principal| {
6741                let allowed_columns = self.select_column_ids_for(table_name, principal)?;
6742                let Some(row) = table.get(row_id, snapshot) else {
6743                    return Ok(None);
6744                };
6745                if allowed.is_some_and(|allowed| !allowed.contains(&row.row_id)) {
6746                    return Ok(None);
6747                }
6748                let mut rows = self.secure_rows_for(table_name, vec![row], principal)?;
6749                if let Some(row) = rows.first_mut() {
6750                    row.columns
6751                        .retain(|column, _| allowed_columns.contains(column));
6752                }
6753                Ok(rows.pop())
6754            },
6755        )
6756    }
6757
6758    /// Run exact ANN reranking over only rows authorized for this database
6759    /// handle. The embedding column still requires normal column access.
6760    pub fn ann_rerank_for_current_principal(
6761        &self,
6762        table_name: &str,
6763        request: &crate::query::AnnRerankRequest,
6764    ) -> Result<Vec<crate::query::AnnRerankHit>> {
6765        self.with_authorized_scored_read_context_at(
6766            table_name,
6767            None,
6768            true,
6769            Some(&ReadAuthorization {
6770                operation: crate::auth::ColumnOperation::Select,
6771                columns: vec![request.column_id],
6772                permissions: Vec::new(),
6773            }),
6774            None,
6775            None,
6776            |table, snapshot, authorization, principal| {
6777                self.require_columns_for(
6778                    table_name,
6779                    crate::auth::ColumnOperation::Select,
6780                    &[request.column_id],
6781                    principal,
6782                )?;
6783                table.ann_rerank_at_with_candidate_authorization_on_generation(
6784                    request,
6785                    snapshot,
6786                    authorization,
6787                    None,
6788                )
6789            },
6790        )
6791    }
6792
6793    /// Run a hybrid scored search over only rows authorized for this database
6794    /// handle. Applies retriever column grants, RLS, and masks to the returned
6795    /// hits.
6796    pub fn search_for_current_principal(
6797        &self,
6798        table_name: &str,
6799        request: &crate::query::SearchRequest,
6800    ) -> Result<Vec<crate::query::SearchHit>> {
6801        let principal = self.principal_snapshot();
6802        self.search_for_principal_with_context(table_name, request, principal.as_ref(), None)
6803    }
6804
6805    /// Run a hybrid scored search as an explicitly validated principal.
6806    ///
6807    /// Protocol adapters use this after validating their session-bound
6808    /// identity. RLS, column grants, masks, cancellation, deadlines, and work
6809    /// limits remain enforced inside the storage authorization boundary.
6810    pub fn search_for_principal_with_context(
6811        &self,
6812        table_name: &str,
6813        request: &crate::query::SearchRequest,
6814        principal: Option<&crate::auth::Principal>,
6815        context: Option<&crate::query::AiExecutionContext>,
6816    ) -> Result<Vec<crate::query::SearchHit>> {
6817        let mut columns = crate::query::condition_columns(&request.must);
6818        for named in &request.retrievers {
6819            columns.push(named.retriever.column_id());
6820        }
6821        if let Some(crate::query::Rerank::ExactVector {
6822            embedding_column, ..
6823        }) = &request.rerank
6824        {
6825            columns.push(*embedding_column);
6826        }
6827        if let Some(proj) = &request.projection {
6828            columns.extend(proj);
6829        }
6830        columns.sort_unstable();
6831        columns.dedup();
6832        let catalog_bound = principal.is_some_and(|principal| principal.user_id != 0);
6833        self.with_authorized_scored_read_context_at(
6834            table_name,
6835            principal,
6836            catalog_bound,
6837            Some(&ReadAuthorization {
6838                operation: crate::auth::ColumnOperation::Select,
6839                columns: columns.clone(),
6840                permissions: Vec::new(),
6841            }),
6842            context,
6843            None,
6844            |table, snapshot, authorization, principal| {
6845                self.require_columns_for(
6846                    table_name,
6847                    crate::auth::ColumnOperation::Select,
6848                    &columns,
6849                    principal,
6850                )?;
6851                let hits = table.search_at_with_candidate_authorization_on_generation(
6852                    request,
6853                    snapshot,
6854                    authorization,
6855                    context,
6856                )?;
6857                let allowed_columns = self.select_column_ids_for(table_name, principal)?;
6858                let mut secured: Vec<crate::query::SearchHit> = Vec::with_capacity(hits.len());
6859                for mut hit in hits {
6860                    let row = crate::memtable::Row {
6861                        row_id: hit.row_id,
6862                        committed_epoch: crate::Epoch(0),
6863                        columns: hit
6864                            .cells
6865                            .iter()
6866                            .cloned()
6867                            .collect::<std::collections::HashMap<u16, crate::Value>>(),
6868                        deleted: false,
6869                        commit_ts: None,
6870                    };
6871                    let mut rows = self.secure_rows_for(table_name, vec![row], principal)?;
6872                    if let Some(mut row) = rows.pop() {
6873                        row.columns
6874                            .retain(|column, _| allowed_columns.contains(column));
6875                        hit.cells = row.columns.into_iter().collect::<Vec<_>>();
6876                        hit.cells.sort_by_key(|(column, _)| *column);
6877                        secured.push(hit);
6878                    }
6879                }
6880                Ok(secured)
6881            },
6882        )
6883    }
6884
6885    /// Embed `text` with the active semantic identity for `embedding_column` and
6886    /// run ANN retrieval, returning hits plus query provenance (P0.7).
6887    pub fn retrieve_text(
6888        &self,
6889        table: &str,
6890        embedding_column: u16,
6891        text: &str,
6892        search_options: crate::embedding::TextSearchOptions,
6893    ) -> Result<crate::embedding::TextRetrieveResult> {
6894        let principal = self.principal_snapshot();
6895        self.retrieve_text_for_principal(
6896            table,
6897            embedding_column,
6898            text,
6899            search_options,
6900            principal.as_ref(),
6901        )
6902    }
6903
6904    pub fn retrieve_text_for_principal(
6905        &self,
6906        table: &str,
6907        embedding_column: u16,
6908        text: &str,
6909        search_options: crate::embedding::TextSearchOptions,
6910        principal: Option<&crate::auth::Principal>,
6911    ) -> Result<crate::embedding::TextRetrieveResult> {
6912        if search_options.k == 0 {
6913            return Err(MongrelError::InvalidArgument(
6914                "retrieve_text k must be greater than zero".into(),
6915            ));
6916        }
6917        let catalog_bound = principal.is_some_and(|p| p.user_id != 0);
6918        let (semantic_identity, registry_generation, expected_dim) = {
6919            let handle = self.table(table)?;
6920            let mut g = handle.lock();
6921            g.ensure_indexes_complete()?;
6922            let schema = g.schema().clone();
6923            let column = schema
6924                .columns
6925                .iter()
6926                .find(|c| c.id == embedding_column)
6927                .ok_or_else(|| {
6928                    MongrelError::ColumnNotFound(format!(
6929                        "embedding column {embedding_column} not found on table {table}"
6930                    ))
6931                })?;
6932            let dim = match column.ty {
6933                crate::schema::TypeId::Embedding { dim } => dim,
6934                _ => {
6935                    return Err(MongrelError::InvalidArgument(format!(
6936                        "column {embedding_column} is not an embedding column"
6937                    )))
6938                }
6939            };
6940            if let Some(identity) = g
6941                .ann_index(embedding_column)
6942                .and_then(|a| a.semantic_identity().cloned())
6943            {
6944                let (generation, provider) = self
6945                    .embedding_providers
6946                    .resolve_by_semantic_identity(&identity)
6947                    .map_err(embedding_error)?;
6948                if provider.dimension() != dim {
6949                    return Err(embedding_error(
6950                        crate::embedding::EmbeddingError::DimensionMismatch {
6951                            expected: dim,
6952                            got: provider.dimension(),
6953                        },
6954                    ));
6955                }
6956                (identity, generation, dim)
6957            } else {
6958                let source = column.embedding_source.clone().ok_or_else(|| {
6959                    embedding_error(crate::embedding::EmbeddingError::NoActiveAnnIdentity(
6960                        embedding_column,
6961                    ))
6962                })?;
6963                let (generation, provider) = self
6964                    .embedding_providers
6965                    .resolve_with_generation(&source)
6966                    .map_err(embedding_error)?;
6967                let identity = provider.semantic_identity();
6968                if identity.dimension != dim {
6969                    return Err(embedding_error(
6970                        crate::embedding::EmbeddingError::DimensionMismatch {
6971                            expected: dim,
6972                            got: identity.dimension,
6973                        },
6974                    ));
6975                }
6976                (identity, generation, dim)
6977            }
6978        };
6979        let (_g, provider) = self
6980            .embedding_providers
6981            .resolve_by_semantic_identity(&semantic_identity)
6982            .map_err(embedding_error)?;
6983        let control = crate::ExecutionControl::new(None);
6984        let response = provider
6985            .embed(crate::embedding::EmbeddingRequest {
6986                texts: &[text],
6987                control: &control,
6988                trace_id: "retrieve-text",
6989            })
6990            .map_err(embedding_error)?;
6991        crate::embedding::validate_response(
6992            provider.as_ref(),
6993            1,
6994            expected_dim,
6995            crate::embedding::EmbeddingLimits::default(),
6996            &response.vectors,
6997        )
6998        .map_err(embedding_error)?;
6999        let query = response.vectors.into_iter().next().ok_or_else(|| {
7000            MongrelError::Other("embedding provider returned no query vector".into())
7001        })?;
7002        if provider.semantic_identity() != semantic_identity {
7003            return Err(embedding_error(
7004                crate::embedding::EmbeddingError::AnnSemanticIdentityMismatch {
7005                    column_id: embedding_column,
7006                    expected: semantic_identity.fingerprint_sha256(),
7007                    got: provider.semantic_identity().fingerprint_sha256(),
7008                },
7009            ));
7010        }
7011        let retriever = crate::query::Retriever::Ann {
7012            column_id: embedding_column,
7013            query,
7014            k: search_options.k,
7015        };
7016        let hits = self.with_authorized_scored_read_context_at(
7017            table,
7018            principal,
7019            catalog_bound,
7020            Some(&ReadAuthorization {
7021                operation: crate::auth::ColumnOperation::Select,
7022                columns: vec![embedding_column],
7023                permissions: Vec::new(),
7024            }),
7025            None,
7026            None,
7027            |table_guard, snapshot, authorization, principal| {
7028                self.require_columns_for(
7029                    table,
7030                    crate::auth::ColumnOperation::Select,
7031                    &[embedding_column],
7032                    principal,
7033                )?;
7034                if let Some(ann) = table_guard.ann_index(embedding_column) {
7035                    if let Some(active) = ann.semantic_identity() {
7036                        if active != &semantic_identity {
7037                            return Err(embedding_error(
7038                                crate::embedding::EmbeddingError::AnnSemanticIdentityMismatch {
7039                                    column_id: embedding_column,
7040                                    expected: semantic_identity.fingerprint_sha256(),
7041                                    got: active.fingerprint_sha256(),
7042                                },
7043                            ));
7044                        }
7045                    }
7046                }
7047                table_guard.retrieve_at_with_candidate_authorization_on_generation(
7048                    &retriever,
7049                    snapshot,
7050                    authorization,
7051                    None,
7052                )
7053            },
7054        )?;
7055        Ok(crate::embedding::TextRetrieveResult {
7056            hits,
7057            provenance: crate::embedding::TextRetrieveProvenance {
7058                semantic_identity,
7059                provider_registry_generation: registry_generation,
7060                query_source_fingerprint: Sha256::digest(text.as_bytes()).into(),
7061                embedding_column,
7062            },
7063        })
7064    }
7065
7066    /// Capture one table snapshot and the security version used to authorize it.
7067    /// The caller must validate the returned version before publishing results.
7068    pub fn authorized_read_snapshot(
7069        &self,
7070        table: &str,
7071        principal: Option<&crate::auth::Principal>,
7072    ) -> Result<AuthorizedReadSnapshot> {
7073        let (security, security_version, effective_principal) = {
7074            let catalog = self.catalog.read();
7075            (
7076                catalog.security.clone(),
7077                catalog.security_version,
7078                self.principal_for_authorized_read(&catalog, principal, false)?,
7079            )
7080        };
7081        let handle = self.table(table)?;
7082        let (table_snapshot, data_generation, allowed_row_ids) = {
7083            let table_handle = handle.lock();
7084            let table_snapshot = table_handle.snapshot();
7085            let data_generation = table_handle.data_generation();
7086            let allowed = self.allowed_row_ids_locked(
7087                table,
7088                &table_handle,
7089                table_snapshot,
7090                (&security, security_version),
7091                effective_principal.as_ref(),
7092                None,
7093            )?;
7094            (
7095                table_snapshot,
7096                data_generation,
7097                allowed.map(|allowed| (*allowed).clone()),
7098            )
7099        };
7100        Ok(AuthorizedReadSnapshot {
7101            table: table.to_string(),
7102            table_snapshot,
7103            data_generation,
7104            security_version,
7105            allowed_row_ids,
7106        })
7107    }
7108
7109    pub fn authorized_read_snapshot_valid(&self, snapshot: &AuthorizedReadSnapshot) -> bool {
7110        if self.catalog.read().security_version != snapshot.security_version {
7111            return false;
7112        }
7113        self.table(&snapshot.table)
7114            .ok()
7115            .is_some_and(|table| table.lock().data_generation() == snapshot.data_generation)
7116    }
7117
7118    pub fn rls_cache_stats(&self) -> RlsCacheStats {
7119        self.rls_cache.lock().stats()
7120    }
7121
7122    /// Read visible rows with column authorization, RLS, and masks applied.
7123    pub fn rows_for(
7124        &self,
7125        table: &str,
7126        principal: Option<&crate::auth::Principal>,
7127    ) -> Result<Vec<crate::memtable::Row>> {
7128        if principal.is_none() && self.principal.read().is_some() {
7129            self.refresh_principal()?;
7130        }
7131        let allowed = self.select_column_ids_for(table, principal)?;
7132        let handle = self.table(table)?;
7133        let rows = {
7134            let table = handle.lock();
7135            table.visible_rows(table.snapshot())?
7136        };
7137        let mut rows = self.secure_rows_for(table, rows, principal)?;
7138        for row in &mut rows {
7139            row.columns.retain(|column, _| allowed.contains(column));
7140        }
7141        Ok(rows)
7142    }
7143
7144    /// Historical rows use the current principal and security catalog against
7145    /// the row values visible at the requested snapshot.
7146    pub fn rows_at_epoch_for_current_principal(
7147        &self,
7148        table_name: &str,
7149        snapshot: Snapshot,
7150    ) -> Result<Vec<crate::memtable::Row>> {
7151        self.with_authorized_read_context(
7152            table_name,
7153            None,
7154            true,
7155            Some(&ReadAuthorization {
7156                operation: crate::auth::ColumnOperation::Select,
7157                columns: Vec::new(),
7158                permissions: Vec::new(),
7159            }),
7160            None,
7161            Some(snapshot),
7162            |table, snapshot, allowed, principal| {
7163                let allowed_columns = self.select_column_ids_for(table_name, principal)?;
7164                let mut rows = table.visible_rows(snapshot)?;
7165                if let Some(allowed) = allowed {
7166                    rows.retain(|row| allowed.contains(&row.row_id));
7167                }
7168                rows = self.secure_rows_for(table_name, rows, principal)?;
7169                for row in &mut rows {
7170                    row.columns
7171                        .retain(|column, _| allowed_columns.contains(column));
7172                }
7173                Ok(rows)
7174            },
7175        )
7176    }
7177
7178    /// Count rows visible to a principal without bypassing RLS.
7179    pub fn count_for(
7180        &self,
7181        table: &str,
7182        principal: Option<&crate::auth::Principal>,
7183    ) -> Result<u64> {
7184        if principal.is_none() && self.principal.read().is_some() {
7185            self.refresh_principal()?;
7186        }
7187        self.select_column_ids_for(table, principal)?;
7188        if self.security_active_for(table) {
7189            Ok(self.rows_for(table, principal)?.len() as u64)
7190        } else {
7191            Ok(self.table(table)?.lock().count())
7192        }
7193    }
7194
7195    /// Authorize and write one native-API row for an explicit principal.
7196    pub fn put_for(
7197        &self,
7198        table: &str,
7199        mut cells: Vec<(u16, crate::memtable::Value)>,
7200        principal: Option<&crate::auth::Principal>,
7201    ) -> Result<RowId> {
7202        let columns = cells.iter().map(|(column, _)| *column).collect::<Vec<_>>();
7203        self.require_columns_for(
7204            table,
7205            crate::auth::ColumnOperation::Insert,
7206            &columns,
7207            principal,
7208        )?;
7209        let handle = self.table(table)?;
7210        let mut table_handle = handle.lock();
7211        table_handle.fill_auto_inc(&mut cells)?;
7212        table_handle.apply_defaults(&mut cells)?;
7213        let mut row = crate::memtable::Row::new(RowId(0), self.epoch.visible());
7214        row.columns.extend(cells.iter().cloned());
7215        self.check_row_policy_for(
7216            table,
7217            crate::security::PolicyCommand::Insert,
7218            &row,
7219            true,
7220            principal,
7221        )?;
7222        table_handle.put(cells)
7223    }
7224
7225    pub fn check_row_policy_for(
7226        &self,
7227        table: &str,
7228        command: crate::security::PolicyCommand,
7229        row: &crate::memtable::Row,
7230        check_new: bool,
7231        principal: Option<&crate::auth::Principal>,
7232    ) -> Result<()> {
7233        let security = self.catalog.read().security.clone();
7234        if !security.rls_enabled(table) {
7235            return Ok(());
7236        }
7237        let cached = self.principal.read().clone();
7238        let principal = principal
7239            .or(cached.as_ref())
7240            .ok_or(MongrelError::AuthRequired)?;
7241        if security.row_allowed(table, command, row, principal, check_new) {
7242            return Ok(());
7243        }
7244        let required = match command {
7245            crate::security::PolicyCommand::Insert => crate::auth::Permission::Insert {
7246                table: table.to_string(),
7247            },
7248            crate::security::PolicyCommand::Update => crate::auth::Permission::Update {
7249                table: table.to_string(),
7250            },
7251            crate::security::PolicyCommand::Select => crate::auth::Permission::Select {
7252                table: table.to_string(),
7253            },
7254            crate::security::PolicyCommand::Delete | crate::security::PolicyCommand::All => {
7255                crate::auth::Permission::Delete {
7256                    table: table.to_string(),
7257                }
7258            }
7259        };
7260        Err(MongrelError::PermissionDenied {
7261            required,
7262            principal: principal.username.clone(),
7263        })
7264    }
7265
7266    /// Durably create or replace a materialized-view definition after its
7267    /// physical table has been populated.
7268    pub fn set_materialized_view(
7269        &self,
7270        definition: crate::catalog::MaterializedViewEntry,
7271    ) -> Result<()> {
7272        self.set_materialized_view_with_epoch(definition)
7273            .map(|_| ())
7274    }
7275
7276    /// Durably create or replace a materialized-view definition and return its epoch.
7277    pub fn set_materialized_view_with_epoch(
7278        &self,
7279        definition: crate::catalog::MaterializedViewEntry,
7280    ) -> Result<Epoch> {
7281        use crate::wal::DdlOp;
7282        use std::sync::atomic::Ordering;
7283
7284        let command = crate::catalog_cmds::CatalogCommand::CreateMaterializedView {
7285            definition: definition.clone(),
7286        };
7287        self.require(&crate::catalog_cmds::required_permission(&command))?;
7288        if self.poisoned.load(Ordering::Relaxed) {
7289            return Err(MongrelError::Other(
7290                "database poisoned by fsync error".into(),
7291            ));
7292        }
7293        if definition.name.is_empty() || definition.query.trim().is_empty() {
7294            return Err(MongrelError::InvalidArgument(
7295                "materialized view name and query must not be empty".into(),
7296            ));
7297        }
7298        // S1A-004: admit the materialized-view replacement as one core
7299        // operation.
7300        let _operation = self.admit_operation()?;
7301        let _ddl = self.ddl_lock.lock();
7302        let _security_write = self.security_write()?;
7303        self.require(&crate::catalog_cmds::required_permission(&command))?;
7304        // S1F-001: the backing-table check validates pure against the current
7305        // catalog first so it surfaces before an epoch is consumed, exactly
7306        // like the legacy pre-bump lookup.
7307        crate::catalog_cmds::apply(&self.catalog.read(), &command)?;
7308        let table_id = self
7309            .catalog
7310            .read()
7311            .live(&definition.name)
7312            .ok_or_else(|| {
7313                MongrelError::NotFound(format!(
7314                    "materialized view table {:?} not found",
7315                    definition.name
7316                ))
7317            })?
7318            .table_id;
7319        let definition_json = DdlOp::encode_materialized_view(&definition)?;
7320        let _commit = self.commit_lock.lock();
7321        let epoch = self.epoch.bump_assigned();
7322        let mut epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
7323        let txn_id = self.alloc_txn_id()?;
7324        let mut next_catalog = self.catalog.read().clone();
7325        self.apply_catalog_command_to(&mut next_catalog, command)?;
7326        next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
7327        let commit_seq = {
7328            let mut wal = self.shared_wal.lock();
7329            let append: Result<u64> = (|| {
7330                wal.append(
7331                    txn_id,
7332                    table_id,
7333                    crate::wal::Op::Ddl(DdlOp::SetMaterializedView {
7334                        name: definition.name.clone(),
7335                        definition_json,
7336                    }),
7337                )?;
7338                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
7339                wal.append_commit(txn_id, epoch, &[])
7340            })();
7341            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
7342        };
7343        let receipt = self.await_durable_commit(txn_id, commit_seq, epoch)?;
7344
7345        let checkpoint = self.checkpoint_catalog_after_durable(next_catalog);
7346        self.finish_durable_publish(epoch, &mut epoch_guard, &receipt, checkpoint)?;
7347        Ok(epoch)
7348    }
7349
7350    /// The filesystem root this database was opened/created at.
7351    pub fn root(&self) -> &Path {
7352        self.durable_root.canonical_path()
7353    }
7354
7355    /// Open a descriptor-pinned view of this database root for durable
7356    /// extension state such as server idempotency receipts.
7357    pub fn durable_root(&self) -> Arc<crate::durable_file::DurableRoot> {
7358        Arc::clone(&self.durable_root)
7359    }
7360
7361    /// Domain-separated authentication key for server idempotency state.
7362    /// Encrypted databases derive it from the in-memory KEK. Plain databases
7363    /// return `None`; their server persists a random key under the pinned root.
7364    pub fn derive_server_idempotency_key(&self) -> Option<zeroize::Zeroizing<[u8; 32]>> {
7365        self.kek
7366            .as_deref()
7367            .map(|kek| kek.derive_subkey(b"mongreldb/server/idempotency/v1"))
7368    }
7369
7370    pub fn is_read_only_replica(&self) -> bool {
7371        self.read_only
7372    }
7373
7374    /// Reject reads whose backing state may require WAL recovery after a
7375    /// post-commit publication failure. Ordinary table/catalog state is made
7376    /// coherent before poison; file-backed external modules use this gate.
7377    pub fn ensure_consistent_read(&self) -> Result<()> {
7378        self.ensure_owner_process()?;
7379        if self.poisoned.load(Ordering::Relaxed) {
7380            return Err(MongrelError::Other(
7381                "database poisoned by post-commit failure; reopen required".into(),
7382            ));
7383        }
7384        Ok(())
7385    }
7386
7387    pub fn set_replication_wal_retention_segments(&self, segments: usize) {
7388        self.replication_wal_retention_segments
7389            .store(segments, std::sync::atomic::Ordering::Relaxed);
7390    }
7391
7392    /// Capture a consistent bootstrap image. DDL, transaction spill/publish,
7393    /// direct table commits, compaction, and WAL append are quiesced while the
7394    /// file image is read. WAL records newer than manifests remain sufficient
7395    /// for recovery, so no flush or compaction is required.
7396    pub fn replication_snapshot(&self) -> Result<crate::replication::ReplicationSnapshot> {
7397        let admin = crate::auth::Permission::Admin;
7398        self.require(&admin)?;
7399        // S1A-004: admit the bootstrap capture as one core operation.
7400        let _operation = self.admit_operation()?;
7401        let operation_principal = self.principal_snapshot();
7402        let _barrier = self.replication_barrier.write();
7403        let _ddl = self.ddl_lock.lock();
7404        let _security = self.security_coordinator.gate.read();
7405        self.require_exact_principal_current(operation_principal.as_ref(), &admin)?;
7406        let mut handles: Vec<_> = self
7407            .tables
7408            .read()
7409            .iter()
7410            .map(|(id, handle)| (*id, handle.clone()))
7411            .collect();
7412        handles.sort_by_key(|(id, _)| *id);
7413        let _table_guards: Vec<_> = handles.iter().map(|(_, handle)| handle.lock()).collect();
7414        let _commit = self.commit_lock.lock();
7415        let mut wal = self.shared_wal.lock();
7416        wal.group_sync()?;
7417        let io_root = self.durable_root.io_path()?;
7418        let wal_dek = crate::encryption::wal_dek_for(self.kek.as_deref());
7419        let records = crate::wal::SharedWal::replay_with_dek(&io_root, wal_dek.as_ref())?;
7420        let epoch = records
7421            .iter()
7422            .filter_map(|record| match &record.op {
7423                crate::wal::Op::TxnCommit { epoch, .. } => Some(*epoch),
7424                _ => None,
7425            })
7426            .max()
7427            .unwrap_or(0)
7428            .max(self.epoch.committed().0);
7429        let files = crate::replication::capture_files(&io_root)?;
7430        let source_id = crate::replication::replication_identity_durable(&self.durable_root)?;
7431        drop(wal);
7432        Ok(crate::replication::ReplicationSnapshot::new(
7433            source_id, epoch, files,
7434        ))
7435    }
7436
7437    /// Create an online, directly-openable backup at `destination`.
7438    ///
7439    /// The short boundary phase quiesces commits/DDL, syncs the WAL, copies
7440    /// mutable metadata, and pins the exact immutable runs named by the copied
7441    /// manifests. Writers resume while those runs stream into a sibling staging
7442    /// directory. A checksummed backup manifest is written last, then the stage
7443    /// is atomically renamed into place.
7444    pub fn hot_backup(&self, destination: impl AsRef<Path>) -> Result<crate::backup::BackupReport> {
7445        let control = crate::ExecutionControl::new(None);
7446        self.hot_backup_controlled(destination, &control, || true)
7447    }
7448
7449    pub(crate) fn hot_backup_to_durable_child(
7450        &self,
7451        parent: &crate::durable_file::DurableRoot,
7452        child: &Path,
7453        control: &crate::ExecutionControl,
7454    ) -> Result<crate::backup::BackupReport> {
7455        let mut components = child.components();
7456        if !matches!(components.next(), Some(std::path::Component::Normal(_)))
7457            || components.next().is_some()
7458        {
7459            return Err(MongrelError::InvalidArgument(
7460                "durable backup child must be one relative path component".into(),
7461            ));
7462        }
7463        let destination_name = child.file_name().ok_or_else(|| {
7464            MongrelError::InvalidArgument("durable backup child has no filename".into())
7465        })?;
7466        let source_root = self.durable_root.io_path()?;
7467        let prepared = prepare_backup_destination_in(&source_root, parent, destination_name)?;
7468        self.hot_backup_prepared(prepared, control, || true)
7469    }
7470
7471    /// Build a backup cooperatively, then invoke `before_publish` immediately
7472    /// before the staging directory is atomically renamed into place.
7473    #[doc(hidden)]
7474    pub fn hot_backup_controlled<F>(
7475        &self,
7476        destination: impl AsRef<Path>,
7477        control: &crate::ExecutionControl,
7478        before_publish: F,
7479    ) -> Result<crate::backup::BackupReport>
7480    where
7481        F: FnOnce() -> bool,
7482    {
7483        let source_root = self.durable_root.io_path()?;
7484        let prepared = prepare_backup_destination(&source_root, destination.as_ref())?;
7485        self.hot_backup_prepared(prepared, control, before_publish)
7486    }
7487
7488    fn hot_backup_prepared<F>(
7489        &self,
7490        mut prepared: PreparedBackupDestination,
7491        control: &crate::ExecutionControl,
7492        before_publish: F,
7493    ) -> Result<crate::backup::BackupReport>
7494    where
7495        F: FnOnce() -> bool,
7496    {
7497        let admin = crate::auth::Permission::Admin;
7498        self.require(&admin)?;
7499        // S1A-004: admit the backup as one core operation; `shutdown()` waits
7500        // for it to drain instead of closing the core mid-copy.
7501        let _operation = self.admit_operation()?;
7502        let operation_principal = self.principal_snapshot();
7503        control.checkpoint()?;
7504        let destination = prepared.destination_path.clone();
7505        let mut before_publish = Some(before_publish);
7506
7507        let outcome = (|| {
7508            control.checkpoint()?;
7509            let barrier = self.replication_barrier.write();
7510            let ddl = self.ddl_lock.lock();
7511            let security = self.security_coordinator.gate.read();
7512            self.require_exact_principal_current(operation_principal.as_ref(), &admin)?;
7513            let mut handles: Vec<_> = self
7514                .tables
7515                .read()
7516                .iter()
7517                .map(|(id, handle)| (*id, handle.clone()))
7518                .collect();
7519            handles.sort_by_key(|(id, _)| *id);
7520            let table_guards: Vec<_> = handles.iter().map(|(_, handle)| handle.lock()).collect();
7521            let commit = self.commit_lock.lock();
7522            let mut wal = self.shared_wal.lock();
7523            wal.group_sync()?;
7524            let epoch = self.epoch.committed().0;
7525            let boundary_unix_nanos = current_unix_nanos();
7526            let source_root = self.durable_root.io_path()?;
7527
7528            let pin_nonce = std::time::SystemTime::now()
7529                .duration_since(std::time::UNIX_EPOCH)
7530                .unwrap_or_default()
7531                .as_nanos();
7532            let file_pin_root = source_root
7533                .join(META_DIR)
7534                .join("backup-pins")
7535                .join(format!("{}-{pin_nonce}", std::process::id()));
7536            std::fs::create_dir_all(&file_pin_root)?;
7537            let _file_pins = BackupFilePins {
7538                root: file_pin_root.clone(),
7539            };
7540            let mut run_files = Vec::new();
7541            for (index, (table_id, _)) in handles.iter().enumerate() {
7542                if index % 256 == 0 {
7543                    control.checkpoint()?;
7544                }
7545                let table = &table_guards[index];
7546                for (run_index, run) in table.run_refs().iter().enumerate() {
7547                    if run_index % 256 == 0 {
7548                        control.checkpoint()?;
7549                    }
7550                    let source = table.run_path(run.run_id as u64);
7551                    let relative = Path::new(TABLES_DIR)
7552                        .join(table_id.to_string())
7553                        .join(crate::engine::RUNS_DIR)
7554                        .join(format!("r-{}.sr", run.run_id));
7555                    let pinned = file_pin_root.join(format!("{table_id}-{}.sr", run.run_id));
7556                    if std::fs::hard_link(&source, &pinned).is_err() {
7557                        crate::backup::copy_file_synced(&source, &pinned)?;
7558                    }
7559                    run_files.push(((*table_id, run.run_id), pinned, relative));
7560                }
7561            }
7562            crate::durable_file::sync_directory(&file_pin_root)?;
7563            let run_keys: Vec<_> = run_files.iter().map(|(key, _, _)| *key).collect();
7564            {
7565                let mut pins = self.backup_pins.lock();
7566                for key in &run_keys {
7567                    *pins.entry(*key).or_insert(0) += 1;
7568                }
7569            }
7570            let _run_pins = RunPins {
7571                pins: Arc::clone(&self.backup_pins),
7572                runs: run_keys,
7573            };
7574            // S1C-004: mirror the backup boundary into every table's unified
7575            // pin registry (PinSource::BackupPitr covers PITR base captures,
7576            // which route through this same path). Version GC must not
7577            // reclaim the boundary versions while their runs stream into the
7578            // stage, and diagnostics must expose the pin. The guards drop
7579            // with `_run_pins` when the backup finishes or fails.
7580            let _registry_pins: Vec<crate::retention::PinGuard> = table_guards
7581                .iter()
7582                .map(|table| {
7583                    table
7584                        .pin_registry()
7585                        .pin(crate::retention::PinSource::BackupPitr, Epoch(epoch))
7586                })
7587                .collect();
7588            let deferred: HashSet<_> = run_files
7589                .iter()
7590                .map(|(_, _, relative)| relative.clone())
7591                .collect();
7592            let mut copied = Vec::new();
7593            copy_backup_boundary(
7594                &source_root,
7595                prepared.stage.as_deref().ok_or_else(|| {
7596                    MongrelError::Other("backup staging root was already released".into())
7597                })?,
7598                &deferred,
7599                &mut copied,
7600                Some(control),
7601            )?;
7602
7603            drop(wal);
7604            drop(commit);
7605            drop(table_guards);
7606            drop(security);
7607            drop(ddl);
7608            drop(barrier);
7609
7610            if let Some(hook) = self.backup_hook.lock().as_ref() {
7611                hook();
7612            }
7613            for (index, (_, source, relative)) in run_files.into_iter().enumerate() {
7614                if index % 256 == 0 {
7615                    control.checkpoint()?;
7616                }
7617                let mut source = crate::durable_file::open_regular_nofollow(&source)?;
7618                prepared
7619                    .stage
7620                    .as_deref()
7621                    .ok_or_else(|| {
7622                        MongrelError::Other("backup staging root was already released".into())
7623                    })?
7624                    .copy_new_from(&relative, &mut source)?;
7625                copied.push(relative);
7626            }
7627
7628            let manifest = crate::backup::BackupManifest::create_controlled_durable(
7629                prepared.stage.as_deref().ok_or_else(|| {
7630                    MongrelError::Other("backup staging root was already released".into())
7631                })?,
7632                epoch,
7633                &copied,
7634                control,
7635            )?;
7636            manifest.write_to_durable(prepared.stage.as_deref().ok_or_else(|| {
7637                MongrelError::Other("backup staging root was already released".into())
7638            })?)?;
7639            control.checkpoint()?;
7640            let publish = before_publish.take().ok_or_else(|| {
7641                MongrelError::Other("backup publication callback already consumed".into())
7642            })?;
7643            if !publish() {
7644                return Err(MongrelError::Cancelled);
7645            }
7646            let final_security = self.security_coordinator.gate.read();
7647            self.require_exact_principal_current(operation_principal.as_ref(), &admin)?;
7648            // Windows pins directories without delete sharing. Release the
7649            // stage handle before renaming that directory, while the parent
7650            // remains descriptor-pinned for the no-replace publication.
7651            drop(prepared.stage.take().ok_or_else(|| {
7652                MongrelError::Other("backup staging root was already released".into())
7653            })?);
7654            let published = std::cell::Cell::new(false);
7655            if let Err(error) = prepared.parent.rename_directory_new_with_after(
7656                Path::new(&prepared.stage_name),
7657                &prepared.parent,
7658                Path::new(&prepared.destination_name),
7659                || published.set(true),
7660            ) {
7661                if published.get() {
7662                    return Err(MongrelError::CommitOutcomeUnknown {
7663                        epoch,
7664                        message: format!("backup publication was not durable: {error}"),
7665                    });
7666                }
7667                return Err(error.into());
7668            }
7669            drop(final_security);
7670            Ok(crate::backup::BackupReport {
7671                destination,
7672                epoch,
7673                boundary_unix_nanos,
7674                files: manifest.files.len(),
7675                bytes: manifest.total_bytes(),
7676            })
7677        })();
7678
7679        if outcome.is_err() {
7680            drop(prepared.stage.take());
7681            let _ = prepared
7682                .parent
7683                .remove_directory_all(Path::new(&prepared.stage_name));
7684        }
7685        outcome
7686    }
7687
7688    /// Return complete committed transactions after `since_epoch`. A gap or a
7689    /// transaction backed by a spilled run requires a fresh bootstrap image.
7690    pub fn replication_batch_since(
7691        &self,
7692        since_epoch: u64,
7693    ) -> Result<crate::replication::ReplicationBatch> {
7694        use crate::wal::Op;
7695
7696        let admin = crate::auth::Permission::Admin;
7697        self.require(&admin)?;
7698        // S1A-004: admit the batch extraction as one core operation.
7699        let _operation = self.admit_operation()?;
7700        let operation_principal = self.principal_snapshot();
7701
7702        let mut wal = self.shared_wal.lock();
7703        wal.group_sync()?;
7704        let wal_dek = crate::encryption::wal_dek_for(self.kek.as_deref());
7705        let records = crate::wal::SharedWal::replay_with_dek(&self.root, wal_dek.as_ref())?;
7706        drop(wal);
7707
7708        let commits: HashMap<u64, u64> = records
7709            .iter()
7710            .filter_map(|record| match &record.op {
7711                Op::TxnCommit { epoch, .. } => Some((record.txn_id, *epoch)),
7712                _ => None,
7713            })
7714            .collect();
7715        let earliest_epoch = commits.values().copied().min();
7716        let current_epoch = commits
7717            .values()
7718            .copied()
7719            .max()
7720            .unwrap_or(0)
7721            .max(self.epoch.committed().0);
7722        let selected: HashSet<u64> = commits
7723            .iter()
7724            .filter_map(|(txn_id, epoch)| (*epoch > since_epoch).then_some(*txn_id))
7725            .collect();
7726        let retention_gap = since_epoch < current_epoch
7727            && since_epoch < crate::replication::replication_wal_floor(&self.root)?;
7728        let spilled = records.iter().any(|record| {
7729            selected.contains(&record.txn_id)
7730                && matches!(
7731                    &record.op,
7732                    Op::TxnCommit { added_runs, .. } if !added_runs.is_empty()
7733                )
7734        });
7735        let records = records
7736            .into_iter()
7737            .filter(|record| record.txn_id != crate::wal::SYSTEM_TXN_ID)
7738            .filter(|record| selected.contains(&record.txn_id))
7739            .collect();
7740        let source_id = crate::replication::replication_identity_durable(&self.durable_root)?;
7741        let batch = crate::replication::ReplicationBatch::complete_for_source(
7742            source_id,
7743            since_epoch,
7744            current_epoch,
7745            earliest_epoch,
7746            retention_gap,
7747            spilled,
7748            records,
7749        )?;
7750        if let Some(hook) = self.replication_hook.lock().as_ref() {
7751            hook();
7752        }
7753        let _security = self.security_coordinator.gate.read();
7754        self.require_exact_principal_current(operation_principal.as_ref(), &admin)?;
7755        Ok(batch)
7756    }
7757
7758    /// Durably append a leader batch to a follower's local WAL and checkpoint
7759    /// its catalog metadata. Security changes apply to this live handle before
7760    /// success returns. The caller must reopen to mount new table state.
7761    pub fn append_replication_batch(
7762        &self,
7763        batch: &crate::replication::ReplicationBatch,
7764    ) -> Result<u64> {
7765        use crate::wal::Op;
7766
7767        if !self.read_only {
7768            return Err(MongrelError::InvalidArgument(
7769                "replication batches may only target a marked replica".into(),
7770            ));
7771        }
7772        // S1A-004: admit the follow-apply as one core operation.
7773        let _operation = self.admit_operation()?;
7774        let current = crate::replication::replica_epoch(&self.root)?;
7775        if batch.is_source_bound() {
7776            let source_id = crate::replication::replica_source_id_durable(&self.durable_root)?;
7777            if batch.source_id != source_id {
7778                return Err(MongrelError::Conflict(
7779                    "replication batch source does not match follower binding".into(),
7780                ));
7781            }
7782        }
7783        if batch.requires_snapshot {
7784            return Err(MongrelError::Conflict(
7785                "replication snapshot required for this batch".into(),
7786            ));
7787        }
7788        batch.validate_proof()?;
7789        if batch.from_epoch != current {
7790            if batch.from_epoch < current && batch.current_epoch == current {
7791                let wal_dek = crate::encryption::wal_dek_for(self.kek.as_deref());
7792                let _wal = self.shared_wal.lock();
7793                let existing: HashSet<(u64, u64)> =
7794                    crate::wal::SharedWal::replay_with_dek(&self.root, wal_dek.as_ref())?
7795                        .into_iter()
7796                        .filter_map(|record| match record.op {
7797                            Op::TxnCommit { epoch, .. } => Some((record.txn_id, epoch)),
7798                            _ => None,
7799                        })
7800                        .collect();
7801                let already_applied = batch.records.iter().all(|record| match &record.op {
7802                    Op::TxnCommit { epoch, .. } => existing.contains(&(record.txn_id, *epoch)),
7803                    _ => true,
7804                });
7805                if already_applied {
7806                    return Ok(current);
7807                }
7808            }
7809            return Err(MongrelError::Conflict(format!(
7810                "replication batch starts at epoch {}, follower is at epoch {current}",
7811                batch.from_epoch
7812            )));
7813        }
7814        if batch.current_epoch < current {
7815            return Err(MongrelError::InvalidArgument(format!(
7816                "replication batch current epoch {} precedes follower epoch {current}",
7817                batch.current_epoch
7818            )));
7819        }
7820        let records = &batch.records;
7821        let mut commits = HashMap::new();
7822        let mut commit_epochs = HashSet::new();
7823        let mut commit_timestamps = HashMap::new();
7824        for record in records {
7825            match &record.op {
7826                Op::TxnCommit { epoch, added_runs } => {
7827                    if !added_runs.is_empty() {
7828                        return Err(MongrelError::Conflict(
7829                            "replication snapshot required for spilled-run transaction".into(),
7830                        ));
7831                    }
7832                    if commits.insert(record.txn_id, *epoch).is_some() {
7833                        return Err(MongrelError::InvalidArgument(format!(
7834                            "duplicate commit for replication transaction {}",
7835                            record.txn_id
7836                        )));
7837                    }
7838                    if *epoch <= current || *epoch > batch.current_epoch {
7839                        return Err(MongrelError::InvalidArgument(format!(
7840                            "replication commit epoch {epoch} is outside ({current}, {}]",
7841                            batch.current_epoch
7842                        )));
7843                    }
7844                    if !commit_epochs.insert(*epoch) {
7845                        return Err(MongrelError::InvalidArgument(format!(
7846                            "duplicate replication commit epoch {epoch}"
7847                        )));
7848                    }
7849                }
7850                Op::CommitTimestamp { unix_nanos } => {
7851                    commit_timestamps.insert(record.txn_id, *unix_nanos);
7852                }
7853                _ => {}
7854            }
7855        }
7856        for record in records {
7857            if record.txn_id == crate::wal::SYSTEM_TXN_ID
7858                || matches!(&record.op, Op::TxnAbort | Op::Flush { .. })
7859            {
7860                return Err(MongrelError::InvalidArgument(
7861                    "replication batch contains a non-committed record".into(),
7862                ));
7863            }
7864            if !commits.contains_key(&record.txn_id) {
7865                return Err(MongrelError::InvalidArgument(format!(
7866                    "incomplete replication transaction {}",
7867                    record.txn_id
7868                )));
7869            }
7870        }
7871        let target_epoch = commits
7872            .values()
7873            .copied()
7874            .filter(|epoch| *epoch > current)
7875            .max()
7876            .unwrap_or(current);
7877        if target_epoch != batch.current_epoch {
7878            return Err(MongrelError::InvalidArgument(format!(
7879                "replication batch ends at epoch {target_epoch}, expected {}",
7880                batch.current_epoch
7881            )));
7882        }
7883        let mut selected: HashSet<u64> = commits
7884            .iter()
7885            .filter_map(|(txn_id, epoch)| (*epoch > current).then_some(*txn_id))
7886            .collect();
7887        if selected.is_empty() {
7888            return Ok(current);
7889        }
7890        let mut wal = self.shared_wal.lock();
7891        wal.group_sync()?;
7892        let wal_dek = crate::encryption::wal_dek_for(self.kek.as_deref());
7893        let existing: HashSet<(u64, u64)> =
7894            crate::wal::SharedWal::replay_with_dek(&self.root, wal_dek.as_ref())?
7895                .into_iter()
7896                .filter_map(|record| match record.op {
7897                    Op::TxnCommit { epoch, .. } => Some((record.txn_id, epoch)),
7898                    _ => None,
7899                })
7900                .collect();
7901        selected.retain(|txn_id| {
7902            commits
7903                .get(txn_id)
7904                .is_some_and(|epoch| !existing.contains(&(*txn_id, *epoch)))
7905        });
7906        for record in records {
7907            if !selected.contains(&record.txn_id) {
7908                continue;
7909            }
7910            match &record.op {
7911                Op::TxnCommit { epoch, added_runs } => {
7912                    let timestamp = commit_timestamps
7913                        .get(&record.txn_id)
7914                        .copied()
7915                        .unwrap_or_else(current_unix_nanos);
7916                    wal.append_commit_at(record.txn_id, Epoch(*epoch), added_runs, timestamp)?;
7917                }
7918                Op::TxnAbort | Op::Flush { .. } | Op::CommitTimestamp { .. } => {}
7919                op => {
7920                    wal.append(record.txn_id, 0, op.clone())?;
7921                }
7922            }
7923        }
7924        if !selected.is_empty() {
7925            wal.group_sync()?;
7926        }
7927        drop(wal);
7928
7929        // Auth mode is selected before `finish_open` replays the WAL. Make the
7930        // catalog transition durable and publish security state to this live
7931        // handle before reporting success.
7932        let mut recovered_catalog = self.catalog.read().clone();
7933        if let Err(error) = recover_ddl_from_wal(
7934            &self.root,
7935            Some(&self.durable_root),
7936            &mut recovered_catalog,
7937            self.meta_dek.as_ref(),
7938            wal_dek.as_ref(),
7939            true,
7940            None,
7941        ) {
7942            return Err(MongrelError::DurableCommit {
7943                epoch: target_epoch,
7944                message: format!(
7945                    "replication WAL is durable but catalog checkpoint failed: {error}"
7946                ),
7947            });
7948        }
7949        let _security = self.security_coordinator.gate.write();
7950        let old_security_version = self.catalog.read().security_version;
7951        let security_changed = old_security_version != recovered_catalog.security_version
7952            || self.catalog.read().require_auth != recovered_catalog.require_auth;
7953        let require_auth = recovered_catalog.require_auth;
7954        let principal = if security_changed {
7955            None
7956        } else {
7957            self.principal.read().as_ref().and_then(|principal| {
7958                Self::resolve_bound_principal_from_catalog(&recovered_catalog, principal)
7959            })
7960        };
7961        if require_auth {
7962            self.auth_state.set_require_auth(true);
7963        }
7964        self.auth_state.set_principal(principal.clone());
7965        *self.principal.write() = principal;
7966        let security_version = recovered_catalog.security_version;
7967        *self.catalog.write() = recovered_catalog;
7968        self.security_coordinator
7969            .version
7970            .store(security_version, Ordering::Release);
7971        if !require_auth {
7972            self.auth_state.set_require_auth(false);
7973        }
7974        if let Err(error) =
7975            crate::replication::reconcile_replica_epoch_durable(&self.durable_root, target_epoch)
7976        {
7977            return Err(MongrelError::DurableCommit {
7978                epoch: target_epoch,
7979                message: format!(
7980                    "replication WAL and catalog are durable but follower watermark failed: {error}"
7981                ),
7982            });
7983        }
7984        Ok(target_epoch)
7985    }
7986
7987    /// Resolve a table name → id (live tables only). pub(crate) so the
7988    /// transaction layer can stage by name.
7989    pub fn table_id(&self, name: &str) -> Result<u64> {
7990        let cat = self.catalog.read();
7991        cat.live(name)
7992            .map(|e| e.table_id)
7993            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))
7994    }
7995
7996    /// Return the stable table id and current schema generation from one
7997    /// catalog snapshot. Callers can bind retries to this identity so a table
7998    /// dropped and recreated under the same name is never mistaken for the
7999    /// original resource.
8000    pub fn table_identity(&self, name: &str) -> Result<(u64, u64)> {
8001        let catalog = self.catalog.read();
8002        catalog
8003            .live(name)
8004            .map(|entry| (entry.table_id, entry.schema.schema_id))
8005            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))
8006    }
8007
8008    pub(crate) fn building_table_id(&self, name: &str) -> Result<u64> {
8009        self.catalog
8010            .read()
8011            .building(name)
8012            .map(|entry| entry.table_id)
8013            .ok_or_else(|| MongrelError::NotFound(format!("building table {name:?} not found")))
8014    }
8015
8016    pub fn procedures(&self) -> Vec<StoredProcedure> {
8017        self.catalog
8018            .read()
8019            .procedures
8020            .iter()
8021            .map(|p| p.procedure.clone())
8022            .collect()
8023    }
8024
8025    pub fn procedure(&self, name: &str) -> Option<StoredProcedure> {
8026        self.catalog
8027            .read()
8028            .procedures
8029            .iter()
8030            .find(|p| p.procedure.name == name)
8031            .map(|p| p.procedure.clone())
8032    }
8033
8034    pub fn create_procedure(&self, procedure: StoredProcedure) -> Result<StoredProcedure> {
8035        self.create_procedure_inner(procedure, None)
8036    }
8037
8038    pub fn create_procedure_controlled<F>(
8039        &self,
8040        procedure: StoredProcedure,
8041        mut before_publish: F,
8042    ) -> Result<StoredProcedure>
8043    where
8044        F: FnMut() -> Result<()>,
8045    {
8046        self.create_procedure_inner(procedure, Some(&mut before_publish))
8047    }
8048
8049    fn create_procedure_inner(
8050        &self,
8051        mut procedure: StoredProcedure,
8052        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
8053    ) -> Result<StoredProcedure> {
8054        let command = crate::catalog_cmds::CatalogCommand::CreateProcedure {
8055            procedure: procedure.clone(),
8056        };
8057        self.require(&crate::catalog_cmds::required_permission(&command))?;
8058        let _g = self.ddl_lock.lock();
8059        let _security_write = self.security_write()?;
8060        self.require(&crate::catalog_cmds::required_permission(&command))?;
8061        procedure.validate()?;
8062        self.validate_procedure_references(&procedure)?;
8063        // S1F-001: validation runs pure against the current catalog first so
8064        // failures (duplicate name, unknown table) surface before an epoch is
8065        // consumed, exactly like the legacy pre-bump checks.
8066        crate::catalog_cmds::apply(&self.catalog.read(), &command)?;
8067        let commit_lock = Arc::clone(&self.commit_lock);
8068        let _c = commit_lock.lock();
8069        let epoch = self.epoch.bump_assigned();
8070        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
8071        procedure.created_epoch = epoch.0;
8072        procedure.updated_epoch = epoch.0;
8073        let command = crate::catalog_cmds::CatalogCommand::CreateProcedure {
8074            procedure: procedure.clone(),
8075        };
8076        let mut next_catalog = self.catalog.read().clone();
8077        self.apply_catalog_command_to(&mut next_catalog, command)?;
8078        next_catalog.db_epoch = epoch.0;
8079        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
8080        Ok(procedure)
8081    }
8082
8083    pub fn create_or_replace_procedure(
8084        &self,
8085        procedure: StoredProcedure,
8086    ) -> Result<StoredProcedure> {
8087        self.create_or_replace_procedure_inner(procedure, None)
8088    }
8089
8090    pub fn create_or_replace_procedure_controlled<F>(
8091        &self,
8092        procedure: StoredProcedure,
8093        mut before_publish: F,
8094    ) -> Result<StoredProcedure>
8095    where
8096        F: FnMut() -> Result<()>,
8097    {
8098        self.create_or_replace_procedure_inner(procedure, Some(&mut before_publish))
8099    }
8100
8101    fn create_or_replace_procedure_inner(
8102        &self,
8103        procedure: StoredProcedure,
8104        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
8105    ) -> Result<StoredProcedure> {
8106        let command = crate::catalog_cmds::CatalogCommand::ReplaceProcedure {
8107            procedure: procedure.clone(),
8108        };
8109        self.require(&crate::catalog_cmds::required_permission(&command))?;
8110        let _g = self.ddl_lock.lock();
8111        let _security_write = self.security_write()?;
8112        self.require(&crate::catalog_cmds::required_permission(&command))?;
8113        procedure.validate()?;
8114        self.validate_procedure_references(&procedure)?;
8115        // S1F-001: validation runs pure against the current catalog first so
8116        // structural failures surface before an epoch is consumed, exactly
8117        // like the legacy pre-bump checks.
8118        crate::catalog_cmds::apply(&self.catalog.read(), &command)?;
8119        let commit_lock = Arc::clone(&self.commit_lock);
8120        let _c = commit_lock.lock();
8121        let epoch = self.epoch.bump_assigned();
8122        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
8123        let mut next_catalog = self.catalog.read().clone();
8124        // Resolve the replacement image against the candidate catalog: the
8125        // engine stamps version/epochs from the commit epoch (preserving the
8126        // original created_epoch on replacement), then the command record
8127        // carries that resolved image.
8128        let replaced = match next_catalog
8129            .procedures
8130            .iter()
8131            .position(|p| p.procedure.name == procedure.name)
8132        {
8133            Some(idx) => next_catalog.procedures[idx]
8134                .procedure
8135                .replaced(procedure.clone(), epoch.0)?,
8136            None => {
8137                let mut next = procedure;
8138                next.created_epoch = epoch.0;
8139                next.updated_epoch = epoch.0;
8140                next
8141            }
8142        };
8143        let command = crate::catalog_cmds::CatalogCommand::ReplaceProcedure {
8144            procedure: replaced.clone(),
8145        };
8146        self.apply_catalog_command_to(&mut next_catalog, command)?;
8147        next_catalog.db_epoch = epoch.0;
8148        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
8149        Ok(replaced)
8150    }
8151
8152    pub fn drop_procedure(&self, name: &str) -> Result<()> {
8153        self.drop_procedure_with_epoch(name).map(|_| ())
8154    }
8155
8156    pub fn drop_procedure_with_epoch(&self, name: &str) -> Result<Epoch> {
8157        self.drop_procedure_with_epoch_inner(name, None)
8158    }
8159
8160    pub fn drop_procedure_with_epoch_controlled<F>(
8161        &self,
8162        name: &str,
8163        mut before_publish: F,
8164    ) -> Result<Epoch>
8165    where
8166        F: FnMut() -> Result<()>,
8167    {
8168        self.drop_procedure_with_epoch_inner(name, Some(&mut before_publish))
8169    }
8170
8171    fn drop_procedure_with_epoch_inner(
8172        &self,
8173        name: &str,
8174        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
8175    ) -> Result<Epoch> {
8176        let command = crate::catalog_cmds::CatalogCommand::DropProcedure {
8177            name: name.to_string(),
8178        };
8179        self.require(&crate::catalog_cmds::required_permission(&command))?;
8180        let _g = self.ddl_lock.lock();
8181        let _security_write = self.security_write()?;
8182        self.require(&crate::catalog_cmds::required_permission(&command))?;
8183        let commit_lock = Arc::clone(&self.commit_lock);
8184        let _c = commit_lock.lock();
8185        let epoch = self.epoch.bump_assigned();
8186        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
8187        let mut next_catalog = self.catalog.read().clone();
8188        self.apply_catalog_command_to(&mut next_catalog, command)?;
8189        next_catalog.db_epoch = epoch.0;
8190        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
8191        Ok(epoch)
8192    }
8193
8194    // ── User / role / credentials management ─────────────────────────────
8195
8196    /// List all catalog users (password hashes included — callers should not
8197    /// serialize them externally).
8198    pub fn users(&self) -> Vec<crate::auth::UserEntry> {
8199        self.catalog.read().users.clone()
8200    }
8201
8202    /// Resolve only the stable, non-secret identity fields needed to scope
8203    /// request receipts. Password hashes never leave the catalog lock.
8204    pub fn user_identity(&self, username: &str) -> Option<(u64, u64)> {
8205        self.catalog
8206            .read()
8207            .users
8208            .iter()
8209            .find(|user| user.username == username)
8210            .map(|user| (user.id, user.created_epoch))
8211    }
8212
8213    /// SCRAM verifier material for the current catalog user generation.
8214    pub fn user_scram_verifier(
8215        &self,
8216        username: &str,
8217    ) -> Option<crate::security_hardening::ScramVerifier> {
8218        self.catalog
8219            .read()
8220            .users
8221            .iter()
8222            .find(|user| user.username == username)
8223            .and_then(|user| user.scram_sha_256.clone())
8224    }
8225
8226    /// Current catalog authorization generation. Retry bindings can include
8227    /// this value to fail closed after roles, grants, or row policies change.
8228    pub fn security_version(&self) -> u64 {
8229        self.catalog.read().security_version
8230    }
8231
8232    /// List all catalog roles.
8233    pub fn roles(&self) -> Vec<crate::auth::RoleEntry> {
8234        self.catalog.read().roles.clone()
8235    }
8236
8237    /// Create a new user with an Argon2id-hashed password.
8238    pub fn create_user(&self, username: &str, password: &str) -> Result<crate::auth::UserEntry> {
8239        self.require(&crate::auth::Permission::Admin)?;
8240        let hash = crate::auth::hash_password(password).map_err(MongrelError::Other)?;
8241        let scram = crate::auth::scram_verifier(password).map_err(MongrelError::Other)?;
8242        let mysql = crate::auth::MysqlCachingSha2Verifier::from_password(password);
8243        self.create_user_with_password_hash_inner(username, hash, Some((scram, mysql)), None)
8244    }
8245
8246    /// Create a user from a password hash prepared before a commit fence.
8247    pub fn create_user_with_password_hash(
8248        &self,
8249        username: &str,
8250        hash: String,
8251    ) -> Result<crate::auth::UserEntry> {
8252        self.create_user_with_password_hash_inner(username, hash, None, None)
8253    }
8254
8255    pub fn create_user_with_password_hash_controlled<F>(
8256        &self,
8257        username: &str,
8258        hash: String,
8259        mut before_publish: F,
8260    ) -> Result<crate::auth::UserEntry>
8261    where
8262        F: FnMut() -> Result<()>,
8263    {
8264        self.create_user_with_password_hash_inner(username, hash, None, Some(&mut before_publish))
8265    }
8266
8267    fn create_user_with_password_hash_inner(
8268        &self,
8269        username: &str,
8270        hash: String,
8271        verifiers: Option<(
8272            crate::security_hardening::ScramVerifier,
8273            crate::auth::MysqlCachingSha2Verifier,
8274        )>,
8275        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
8276    ) -> Result<crate::auth::UserEntry> {
8277        // S1F-001: the mutation is a versioned catalog command; its required
8278        // permission is checked against the caller principal first (identical
8279        // to the legacy hardcoded Admin gate).
8280        let command = match &verifiers {
8281            Some((scram_sha_256, mysql_caching_sha2)) => {
8282                crate::catalog_cmds::CatalogCommand::CreateUserWithAuthVerifiers {
8283                    username: username.to_string(),
8284                    password_hash: hash.clone(),
8285                    scram_sha_256: scram_sha_256.clone(),
8286                    mysql_caching_sha2: mysql_caching_sha2.clone(),
8287                    is_admin: false,
8288                    created_epoch: 0,
8289                }
8290            }
8291            None => crate::catalog_cmds::CatalogCommand::CreateUser {
8292                username: username.to_string(),
8293                password_hash: hash.clone(),
8294                is_admin: false,
8295                created_epoch: 0,
8296            },
8297        };
8298        self.require(&crate::catalog_cmds::required_permission(&command))?;
8299        let _ddl = self.ddl_lock.lock();
8300        let _security_write = self.security_write()?;
8301        self.require(&crate::catalog_cmds::required_permission(&command))?;
8302        let _commit = self.commit_lock.lock();
8303        let epoch = self.epoch.bump_assigned();
8304        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
8305        let command = match verifiers {
8306            Some((scram_sha_256, mysql_caching_sha2)) => {
8307                crate::catalog_cmds::CatalogCommand::CreateUserWithAuthVerifiers {
8308                    username: username.to_string(),
8309                    password_hash: hash,
8310                    scram_sha_256,
8311                    mysql_caching_sha2,
8312                    is_admin: false,
8313                    created_epoch: epoch.0,
8314                }
8315            }
8316            None => crate::catalog_cmds::CatalogCommand::CreateUser {
8317                username: username.to_string(),
8318                password_hash: hash,
8319                is_admin: false,
8320                created_epoch: epoch.0,
8321            },
8322        };
8323        let mut next_catalog = self.catalog.read().clone();
8324        let delta = self.apply_catalog_command_to(&mut next_catalog, command)?;
8325        let crate::catalog_cmds::CatalogDelta::UserUpserted(entry) = delta else {
8326            return Err(MongrelError::Other(
8327                "CreateUser resolved to an unexpected catalog delta".into(),
8328            ));
8329        };
8330        next_catalog.db_epoch = epoch.0;
8331        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
8332        Ok(entry)
8333    }
8334
8335    /// Drop a user by username.
8336    pub fn drop_user(&self, username: &str) -> Result<()> {
8337        self.drop_user_with_epoch(username).map(|_| ())
8338    }
8339
8340    pub fn drop_user_with_epoch(&self, username: &str) -> Result<Epoch> {
8341        self.drop_user_with_epoch_inner(username, None)
8342    }
8343
8344    pub fn drop_user_with_epoch_controlled<F>(
8345        &self,
8346        username: &str,
8347        mut before_publish: F,
8348    ) -> Result<Epoch>
8349    where
8350        F: FnMut() -> Result<()>,
8351    {
8352        self.drop_user_with_epoch_inner(username, Some(&mut before_publish))
8353    }
8354
8355    fn drop_user_with_epoch_inner(
8356        &self,
8357        username: &str,
8358        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
8359    ) -> Result<Epoch> {
8360        let command = crate::catalog_cmds::CatalogCommand::DropUser {
8361            username: username.to_string(),
8362        };
8363        self.require(&crate::catalog_cmds::required_permission(&command))?;
8364        let _ddl = self.ddl_lock.lock();
8365        let _security_write = self.security_write()?;
8366        self.require(&crate::catalog_cmds::required_permission(&command))?;
8367        let _commit = self.commit_lock.lock();
8368        let epoch = self.epoch.bump_assigned();
8369        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
8370        let mut next_catalog = self.catalog.read().clone();
8371        self.apply_catalog_command_to(&mut next_catalog, command)?;
8372        next_catalog.db_epoch = epoch.0;
8373        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
8374        Ok(epoch)
8375    }
8376
8377    /// Change a user's password.
8378    pub fn alter_user_password(&self, username: &str, new_password: &str) -> Result<()> {
8379        self.alter_user_password_with_epoch(username, new_password)
8380            .map(|_| ())
8381    }
8382
8383    pub fn alter_user_password_with_epoch(
8384        &self,
8385        username: &str,
8386        new_password: &str,
8387    ) -> Result<Epoch> {
8388        self.require(&crate::auth::Permission::Admin)?;
8389        let hash = crate::auth::hash_password(new_password).map_err(MongrelError::Other)?;
8390        let scram = crate::auth::scram_verifier(new_password).map_err(MongrelError::Other)?;
8391        let mysql = crate::auth::MysqlCachingSha2Verifier::from_password(new_password);
8392        self.alter_user_password_hash_with_epoch_inner(username, hash, Some((scram, mysql)), None)
8393    }
8394
8395    pub fn alter_user_password_hash_with_epoch(
8396        &self,
8397        username: &str,
8398        hash: String,
8399    ) -> Result<Epoch> {
8400        self.alter_user_password_hash_with_epoch_inner(username, hash, None, None)
8401    }
8402
8403    pub fn alter_user_password_hash_with_epoch_controlled<F>(
8404        &self,
8405        username: &str,
8406        hash: String,
8407        mut before_publish: F,
8408    ) -> Result<Epoch>
8409    where
8410        F: FnMut() -> Result<()>,
8411    {
8412        self.alter_user_password_hash_with_epoch_inner(
8413            username,
8414            hash,
8415            None,
8416            Some(&mut before_publish),
8417        )
8418    }
8419
8420    fn alter_user_password_hash_with_epoch_inner(
8421        &self,
8422        username: &str,
8423        hash: String,
8424        verifiers: Option<(
8425            crate::security_hardening::ScramVerifier,
8426            crate::auth::MysqlCachingSha2Verifier,
8427        )>,
8428        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
8429    ) -> Result<Epoch> {
8430        let command = match verifiers {
8431            Some((scram_sha_256, mysql_caching_sha2)) => {
8432                crate::catalog_cmds::CatalogCommand::AlterUserPasswordWithAuthVerifiers {
8433                    username: username.to_string(),
8434                    password_hash: hash,
8435                    scram_sha_256,
8436                    mysql_caching_sha2,
8437                }
8438            }
8439            None => crate::catalog_cmds::CatalogCommand::AlterUserPassword {
8440                username: username.to_string(),
8441                password_hash: hash,
8442            },
8443        };
8444        self.require(&crate::catalog_cmds::required_permission(&command))?;
8445        let _ddl = self.ddl_lock.lock();
8446        let _security_write = self.security_write()?;
8447        self.require(&crate::catalog_cmds::required_permission(&command))?;
8448        let _commit = self.commit_lock.lock();
8449        let epoch = self.epoch.bump_assigned();
8450        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
8451        let mut next_catalog = self.catalog.read().clone();
8452        self.apply_catalog_command_to(&mut next_catalog, command)?;
8453        next_catalog.db_epoch = epoch.0;
8454        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
8455        Ok(epoch)
8456    }
8457
8458    /// Verify credentials. Returns `Some(entry)` on success, `None` on
8459    /// mismatch, `Err` on engine error.
8460    pub fn verify_user(
8461        &self,
8462        username: &str,
8463        password: &str,
8464    ) -> Result<Option<crate::auth::UserEntry>> {
8465        let cat = self.catalog.read();
8466        let Some(user) = cat.users.iter().find(|u| u.username == username) else {
8467            return Ok(None);
8468        };
8469        if user.password_hash.is_empty() {
8470            return Ok(None);
8471        }
8472        let ok = crate::auth::verify_password(password, &user.password_hash)
8473            .map_err(MongrelError::Other)?;
8474        if ok {
8475            Ok(Some(user.clone()))
8476        } else {
8477            Ok(None)
8478        }
8479    }
8480
8481    /// Authenticate and resolve one immutable principal from the same catalog
8482    /// snapshot. Username reuse cannot bridge the password check and principal
8483    /// resolution.
8484    pub fn authenticate_principal(
8485        &self,
8486        username: &str,
8487        password: &str,
8488    ) -> Result<Option<crate::auth::Principal>> {
8489        self.authenticate_principal_inner(username, password, || {})
8490    }
8491
8492    fn authenticate_principal_inner<F>(
8493        &self,
8494        username: &str,
8495        password: &str,
8496        after_verify: F,
8497    ) -> Result<Option<crate::auth::Principal>>
8498    where
8499        F: FnOnce(),
8500    {
8501        let catalog = self.catalog.read();
8502        let Some(user) = catalog.users.iter().find(|user| user.username == username) else {
8503            return Ok(None);
8504        };
8505        if user.password_hash.is_empty()
8506            || !crate::auth::verify_password(password, &user.password_hash)
8507                .map_err(MongrelError::Other)?
8508        {
8509            return Ok(None);
8510        }
8511        after_verify();
8512        Ok(Self::resolve_user_principal_from_catalog(&catalog, user))
8513    }
8514
8515    /// Verify a MySQL 8 `caching_sha2_password` proof and resolve the same
8516    /// live catalog principal used by native SCRAM authentication.
8517    pub fn authenticate_mysql_caching_sha2_principal(
8518        &self,
8519        username: &str,
8520        nonce: &[u8],
8521        proof: &[u8],
8522    ) -> Option<crate::auth::Principal> {
8523        let catalog = self.catalog.read();
8524        let user = catalog
8525            .users
8526            .iter()
8527            .find(|user| user.username == username)?;
8528        if !user.mysql_caching_sha2.as_ref()?.verify(nonce, proof) {
8529            return None;
8530        }
8531        Self::resolve_user_principal_from_catalog(&catalog, user)
8532    }
8533
8534    /// Grant admin privileges to a user (bypasses all permission checks).
8535    pub fn set_user_admin(&self, username: &str, is_admin: bool) -> Result<()> {
8536        self.set_user_admin_with_epoch(username, is_admin)
8537            .map(|_| ())
8538    }
8539
8540    pub fn set_user_admin_with_epoch(
8541        &self,
8542        username: &str,
8543        is_admin: bool,
8544    ) -> Result<Option<Epoch>> {
8545        self.set_user_admin_with_epoch_inner(username, is_admin, None)
8546    }
8547
8548    pub fn set_user_admin_with_epoch_controlled<F>(
8549        &self,
8550        username: &str,
8551        is_admin: bool,
8552        mut before_publish: F,
8553    ) -> Result<Option<Epoch>>
8554    where
8555        F: FnMut() -> Result<()>,
8556    {
8557        self.set_user_admin_with_epoch_inner(username, is_admin, Some(&mut before_publish))
8558    }
8559
8560    fn set_user_admin_with_epoch_inner(
8561        &self,
8562        username: &str,
8563        is_admin: bool,
8564        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
8565    ) -> Result<Option<Epoch>> {
8566        let command = crate::catalog_cmds::CatalogCommand::SetUserAdmin {
8567            username: username.to_string(),
8568            is_admin,
8569        };
8570        self.require(&crate::catalog_cmds::required_permission(&command))?;
8571        let _ddl = self.ddl_lock.lock();
8572        let _security_write = self.security_write()?;
8573        self.require(&crate::catalog_cmds::required_permission(&command))?;
8574        let _commit = self.commit_lock.lock();
8575        // Idempotent no-ops publish nothing and consume no epoch (the legacy
8576        // short-circuit), so validation runs pure first and only real changes
8577        // are applied and recorded.
8578        let delta = crate::catalog_cmds::apply(&self.catalog.read(), &command)?;
8579        if matches!(delta, crate::catalog_cmds::CatalogDelta::NoOp) {
8580            return Ok(None);
8581        }
8582        let epoch = self.epoch.bump_assigned();
8583        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
8584        let mut next_catalog = self.catalog.read().clone();
8585        self.apply_catalog_command_to(&mut next_catalog, command)?;
8586        next_catalog.db_epoch = epoch.0;
8587        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
8588        Ok(Some(epoch))
8589    }
8590
8591    /// Create a new role.
8592    pub fn create_role(&self, name: &str) -> Result<crate::auth::RoleEntry> {
8593        self.create_role_inner(name, None)
8594    }
8595
8596    pub fn create_role_controlled<F>(
8597        &self,
8598        name: &str,
8599        mut before_publish: F,
8600    ) -> Result<crate::auth::RoleEntry>
8601    where
8602        F: FnMut() -> Result<()>,
8603    {
8604        self.create_role_inner(name, Some(&mut before_publish))
8605    }
8606
8607    fn create_role_inner(
8608        &self,
8609        name: &str,
8610        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
8611    ) -> Result<crate::auth::RoleEntry> {
8612        let command = crate::catalog_cmds::CatalogCommand::CreateRole {
8613            name: name.to_string(),
8614            created_epoch: 0, // stamped after the commit epoch is assigned
8615        };
8616        self.require(&crate::catalog_cmds::required_permission(&command))?;
8617        let _ddl = self.ddl_lock.lock();
8618        let _security_write = self.security_write()?;
8619        self.require(&crate::catalog_cmds::required_permission(&command))?;
8620        let _commit = self.commit_lock.lock();
8621        let epoch = self.epoch.bump_assigned();
8622        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
8623        let command = crate::catalog_cmds::CatalogCommand::CreateRole {
8624            name: name.to_string(),
8625            created_epoch: epoch.0,
8626        };
8627        let mut next_catalog = self.catalog.read().clone();
8628        let delta = self.apply_catalog_command_to(&mut next_catalog, command)?;
8629        let crate::catalog_cmds::CatalogDelta::RoleUpserted(entry) = delta else {
8630            return Err(MongrelError::Other(
8631                "CreateRole resolved to an unexpected catalog delta".into(),
8632            ));
8633        };
8634        next_catalog.db_epoch = epoch.0;
8635        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
8636        Ok(entry)
8637    }
8638
8639    /// Drop a role by name.
8640    pub fn drop_role(&self, name: &str) -> Result<()> {
8641        self.drop_role_with_epoch(name).map(|_| ())
8642    }
8643
8644    pub fn drop_role_with_epoch(&self, name: &str) -> Result<Epoch> {
8645        self.drop_role_with_epoch_inner(name, None)
8646    }
8647
8648    pub fn drop_role_with_epoch_controlled<F>(
8649        &self,
8650        name: &str,
8651        mut before_publish: F,
8652    ) -> Result<Epoch>
8653    where
8654        F: FnMut() -> Result<()>,
8655    {
8656        self.drop_role_with_epoch_inner(name, Some(&mut before_publish))
8657    }
8658
8659    fn drop_role_with_epoch_inner(
8660        &self,
8661        name: &str,
8662        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
8663    ) -> Result<Epoch> {
8664        let command = crate::catalog_cmds::CatalogCommand::DropRole {
8665            name: name.to_string(),
8666        };
8667        self.require(&crate::catalog_cmds::required_permission(&command))?;
8668        let _ddl = self.ddl_lock.lock();
8669        let _security_write = self.security_write()?;
8670        self.require(&crate::catalog_cmds::required_permission(&command))?;
8671        let _commit = self.commit_lock.lock();
8672        let epoch = self.epoch.bump_assigned();
8673        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
8674        let mut next_catalog = self.catalog.read().clone();
8675        self.apply_catalog_command_to(&mut next_catalog, command)?;
8676        next_catalog.db_epoch = epoch.0;
8677        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
8678        Ok(epoch)
8679    }
8680
8681    /// Grant a role to a user.
8682    pub fn grant_role(&self, username: &str, role_name: &str) -> Result<()> {
8683        self.grant_role_with_epoch(username, role_name).map(|_| ())
8684    }
8685
8686    pub fn grant_role_with_epoch(&self, username: &str, role_name: &str) -> Result<Option<Epoch>> {
8687        self.grant_role_with_epoch_inner(username, role_name, None)
8688    }
8689
8690    pub fn grant_role_with_epoch_controlled<F>(
8691        &self,
8692        username: &str,
8693        role_name: &str,
8694        mut before_publish: F,
8695    ) -> Result<Option<Epoch>>
8696    where
8697        F: FnMut() -> Result<()>,
8698    {
8699        self.grant_role_with_epoch_inner(username, role_name, Some(&mut before_publish))
8700    }
8701
8702    fn grant_role_with_epoch_inner(
8703        &self,
8704        username: &str,
8705        role_name: &str,
8706        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
8707    ) -> Result<Option<Epoch>> {
8708        let command = crate::catalog_cmds::CatalogCommand::GrantRole {
8709            username: username.to_string(),
8710            role: role_name.to_string(),
8711        };
8712        self.require(&crate::catalog_cmds::required_permission(&command))?;
8713        let _ddl = self.ddl_lock.lock();
8714        let _security_write = self.security_write()?;
8715        self.require(&crate::catalog_cmds::required_permission(&command))?;
8716        let _commit = self.commit_lock.lock();
8717        // Idempotent no-ops publish nothing and consume no epoch (the legacy
8718        // short-circuit), so validation runs pure first and only real changes
8719        // are applied and recorded.
8720        let delta = crate::catalog_cmds::apply(&self.catalog.read(), &command)?;
8721        if matches!(delta, crate::catalog_cmds::CatalogDelta::NoOp) {
8722            return Ok(None);
8723        }
8724        let epoch = self.epoch.bump_assigned();
8725        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
8726        let mut next_catalog = self.catalog.read().clone();
8727        self.apply_catalog_command_to(&mut next_catalog, command)?;
8728        next_catalog.db_epoch = epoch.0;
8729        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
8730        Ok(Some(epoch))
8731    }
8732
8733    /// Revoke a role from a user.
8734    pub fn revoke_role(&self, username: &str, role_name: &str) -> Result<()> {
8735        self.revoke_role_with_epoch(username, role_name).map(|_| ())
8736    }
8737
8738    pub fn revoke_role_with_epoch(&self, username: &str, role_name: &str) -> Result<Option<Epoch>> {
8739        self.revoke_role_with_epoch_inner(username, role_name, None)
8740    }
8741
8742    pub fn revoke_role_with_epoch_controlled<F>(
8743        &self,
8744        username: &str,
8745        role_name: &str,
8746        mut before_publish: F,
8747    ) -> Result<Option<Epoch>>
8748    where
8749        F: FnMut() -> Result<()>,
8750    {
8751        self.revoke_role_with_epoch_inner(username, role_name, Some(&mut before_publish))
8752    }
8753
8754    fn revoke_role_with_epoch_inner(
8755        &self,
8756        username: &str,
8757        role_name: &str,
8758        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
8759    ) -> Result<Option<Epoch>> {
8760        let command = crate::catalog_cmds::CatalogCommand::RevokeRole {
8761            username: username.to_string(),
8762            role: role_name.to_string(),
8763        };
8764        self.require(&crate::catalog_cmds::required_permission(&command))?;
8765        let _ddl = self.ddl_lock.lock();
8766        let _security_write = self.security_write()?;
8767        self.require(&crate::catalog_cmds::required_permission(&command))?;
8768        let _commit = self.commit_lock.lock();
8769        // Idempotent no-ops publish nothing and consume no epoch (the legacy
8770        // short-circuit), so validation runs pure first and only real changes
8771        // are applied and recorded.
8772        let delta = crate::catalog_cmds::apply(&self.catalog.read(), &command)?;
8773        if matches!(delta, crate::catalog_cmds::CatalogDelta::NoOp) {
8774            return Ok(None);
8775        }
8776        let epoch = self.epoch.bump_assigned();
8777        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
8778        let mut next_catalog = self.catalog.read().clone();
8779        self.apply_catalog_command_to(&mut next_catalog, command)?;
8780        next_catalog.db_epoch = epoch.0;
8781        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
8782        Ok(Some(epoch))
8783    }
8784
8785    /// Grant a permission to a role.
8786    pub fn grant_permission(
8787        &self,
8788        role_name: &str,
8789        permission: crate::auth::Permission,
8790    ) -> Result<()> {
8791        self.grant_permission_with_epoch(role_name, permission)
8792            .map(|_| ())
8793    }
8794
8795    pub fn grant_permission_with_epoch(
8796        &self,
8797        role_name: &str,
8798        permission: crate::auth::Permission,
8799    ) -> Result<Option<Epoch>> {
8800        self.grant_permission_with_epoch_inner(role_name, permission, None)
8801    }
8802
8803    pub fn grant_permission_with_epoch_controlled<F>(
8804        &self,
8805        role_name: &str,
8806        permission: crate::auth::Permission,
8807        mut before_publish: F,
8808    ) -> Result<Option<Epoch>>
8809    where
8810        F: FnMut() -> Result<()>,
8811    {
8812        self.grant_permission_with_epoch_inner(role_name, permission, Some(&mut before_publish))
8813    }
8814
8815    fn grant_permission_with_epoch_inner(
8816        &self,
8817        role_name: &str,
8818        permission: crate::auth::Permission,
8819        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
8820    ) -> Result<Option<Epoch>> {
8821        let command = crate::catalog_cmds::CatalogCommand::GrantPermission {
8822            role: role_name.to_string(),
8823            permission,
8824        };
8825        self.require(&crate::catalog_cmds::required_permission(&command))?;
8826        let _ddl = self.ddl_lock.lock();
8827        let _security_write = self.security_write()?;
8828        self.require(&crate::catalog_cmds::required_permission(&command))?;
8829        let _commit = self.commit_lock.lock();
8830        // Idempotent no-ops publish nothing and consume no epoch (the legacy
8831        // short-circuit), so validation runs pure first and only real changes
8832        // are applied and recorded.
8833        let delta = crate::catalog_cmds::apply(&self.catalog.read(), &command)?;
8834        if matches!(delta, crate::catalog_cmds::CatalogDelta::NoOp) {
8835            return Ok(None);
8836        }
8837        let epoch = self.epoch.bump_assigned();
8838        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
8839        let mut next_catalog = self.catalog.read().clone();
8840        self.apply_catalog_command_to(&mut next_catalog, command)?;
8841        next_catalog.db_epoch = epoch.0;
8842        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
8843        Ok(Some(epoch))
8844    }
8845
8846    /// Revoke a permission from a role.
8847    pub fn revoke_permission(
8848        &self,
8849        role_name: &str,
8850        permission: crate::auth::Permission,
8851    ) -> Result<()> {
8852        self.revoke_permission_with_epoch(role_name, permission)
8853            .map(|_| ())
8854    }
8855
8856    pub fn revoke_permission_with_epoch(
8857        &self,
8858        role_name: &str,
8859        permission: crate::auth::Permission,
8860    ) -> Result<Option<Epoch>> {
8861        self.revoke_permission_with_epoch_inner(role_name, permission, None)
8862    }
8863
8864    pub fn revoke_permission_with_epoch_controlled<F>(
8865        &self,
8866        role_name: &str,
8867        permission: crate::auth::Permission,
8868        mut before_publish: F,
8869    ) -> Result<Option<Epoch>>
8870    where
8871        F: FnMut() -> Result<()>,
8872    {
8873        self.revoke_permission_with_epoch_inner(role_name, permission, Some(&mut before_publish))
8874    }
8875
8876    fn revoke_permission_with_epoch_inner(
8877        &self,
8878        role_name: &str,
8879        permission: crate::auth::Permission,
8880        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
8881    ) -> Result<Option<Epoch>> {
8882        let command = crate::catalog_cmds::CatalogCommand::RevokePermission {
8883            role: role_name.to_string(),
8884            permission,
8885        };
8886        self.require(&crate::catalog_cmds::required_permission(&command))?;
8887        let _ddl = self.ddl_lock.lock();
8888        let _security_write = self.security_write()?;
8889        self.require(&crate::catalog_cmds::required_permission(&command))?;
8890        let _commit = self.commit_lock.lock();
8891        // Idempotent no-ops publish nothing and consume no epoch (the legacy
8892        // short-circuit), so validation runs pure first and only real changes
8893        // are applied and recorded.
8894        let delta = crate::catalog_cmds::apply(&self.catalog.read(), &command)?;
8895        if matches!(delta, crate::catalog_cmds::CatalogDelta::NoOp) {
8896            return Ok(None);
8897        }
8898        let epoch = self.epoch.bump_assigned();
8899        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
8900        let mut next_catalog = self.catalog.read().clone();
8901        self.apply_catalog_command_to(&mut next_catalog, command)?;
8902        next_catalog.db_epoch = epoch.0;
8903        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
8904        Ok(Some(epoch))
8905    }
8906
8907    /// Resolve a user into a [`crate::auth::Principal`] by collecting all
8908    /// permissions from their roles. Returns `None` if the user doesn't exist.
8909    pub fn resolve_principal(&self, username: &str) -> Option<crate::auth::Principal> {
8910        let cat = self.catalog.read();
8911        Self::resolve_principal_from_catalog(&cat, username)
8912    }
8913
8914    /// Re-resolve only when the immutable user identity still exists. This is
8915    /// the server/session validation path; username reuse never matches.
8916    pub fn resolve_current_principal(
8917        &self,
8918        principal: &crate::auth::Principal,
8919    ) -> Option<crate::auth::Principal> {
8920        Self::resolve_bound_principal_from_catalog(&self.catalog.read(), principal)
8921    }
8922
8923    /// Resolve a username to a [`Principal`] directly from a catalog snapshot,
8924    /// without needing a constructed `Database`. Used by the credentialed open
8925    /// path (which must verify credentials before the `Database` exists) and
8926    /// by [`resolve_principal`](Self::resolve_principal).
8927    fn resolve_principal_from_catalog(
8928        cat: &Catalog,
8929        username: &str,
8930    ) -> Option<crate::auth::Principal> {
8931        let user = cat.users.iter().find(|u| u.username == username)?;
8932        Self::resolve_user_principal_from_catalog(cat, user)
8933    }
8934
8935    fn resolve_bound_principal_from_catalog(
8936        cat: &Catalog,
8937        principal: &crate::auth::Principal,
8938    ) -> Option<crate::auth::Principal> {
8939        let user = cat.users.iter().find(|user| {
8940            user.id == principal.user_id
8941                && user.created_epoch == principal.created_epoch
8942                && user.username == principal.username
8943        })?;
8944        Self::resolve_user_principal_from_catalog(cat, user)
8945    }
8946
8947    fn resolve_user_principal_from_catalog(
8948        cat: &Catalog,
8949        user: &crate::auth::UserEntry,
8950    ) -> Option<crate::auth::Principal> {
8951        let mut permissions = Vec::new();
8952        for role_name in &user.roles {
8953            if let Some(role) = cat.roles.iter().find(|r| &r.name == role_name) {
8954                permissions.extend(role.permissions.iter().cloned());
8955            }
8956        }
8957        Some(crate::auth::Principal {
8958            user_id: user.id,
8959            created_epoch: user.created_epoch,
8960            username: user.username.clone(),
8961            is_admin: user.is_admin,
8962            roles: user.roles.clone(),
8963            permissions,
8964        })
8965    }
8966
8967    /// Check whether a user has a specific permission (via their roles).
8968    pub fn check_permission(&self, username: &str, permission: &crate::auth::Permission) -> bool {
8969        match self.resolve_principal(username) {
8970            Some(p) => p.has_permission(permission),
8971            None => false,
8972        }
8973    }
8974
8975    /// Returns `true` if this database's catalog has `require_auth = true`.
8976    /// When true, every operation consults the cached [`Principal`] via
8977    /// [`require`](Self::require).
8978    pub fn require_auth_enabled(&self) -> bool {
8979        self.catalog.read().require_auth
8980    }
8981
8982    /// A snapshot of the cached principal for this handle, if any. `None` for
8983    /// databases opened without credentials (the default). Returns a clone
8984    /// because the principal lives behind an `RwLock`.
8985    pub fn principal(&self) -> Option<crate::auth::Principal> {
8986        self.principal.read().clone()
8987    }
8988
8989    /// Build a `TableAuthChecker` from the current auth state. Used when
8990    /// mounting a new table (`create_table`) so the table inherits the
8991    /// database's enforcement configuration. The checker reads the live
8992    /// `require_auth` flag and cached principal, so changes via `enable_auth`
8993    /// / `refresh_principal` propagate to already-mounted tables.
8994    fn table_auth_checker(&self) -> Option<Arc<dyn crate::auth_state::TableAuthChecker>> {
8995        if self.shared {
8996            return None;
8997        }
8998        Some(Arc::new(crate::auth_state::DefaultTableAuthChecker::new(
8999            self.auth_state.clone(),
9000        )))
9001    }
9002
9003    /// Re-resolve the cached principal from the shared current catalog.
9004    /// Long-lived
9005    /// handles (e.g. a daemon) call this after a `REVOKE` or role change —
9006    /// possibly made by a different handle to the same database — to pick up
9007    /// the new effective permissions without re-verifying the password.
9008    ///
9009    /// The process-wide security version reloads from disk only when another
9010    /// handle published a newer catalog. The username is taken from
9011    /// the existing cached principal; if the user has since been dropped,
9012    /// returns [`MongrelError::InvalidCredentials`].
9013    ///
9014    /// No-op (returns `Ok(())`) on a credentialless database, or on a
9015    /// credentialed database whose cached principal is `None`.
9016    pub fn refresh_principal(&self) -> Result<()> {
9017        let previous = match self.principal.read().clone() {
9018            Some(principal) => principal,
9019            None => return Ok(()),
9020        };
9021        // Service principals are runtime identities, not catalog users. Their
9022        // immutable scopes are stored on the handle and never re-resolved by
9023        // username.
9024        if previous.user_id == 0 {
9025            return Ok(());
9026        }
9027        let observed_version = self.security_coordinator.version.load(Ordering::Acquire);
9028        self.refresh_security_catalog_if_stale(observed_version)?;
9029        let cat = self.catalog.read();
9030        match Self::resolve_bound_principal_from_catalog(&cat, &previous) {
9031            Some(p) => {
9032                *self.principal.write() = Some(p.clone());
9033                // Update the shared auth state so mounted Tables see the new
9034                // permissions immediately (Tables read from AuthState, not from
9035                // self.principal).
9036                self.auth_state.set_principal(Some(p));
9037                Ok(())
9038            }
9039            None => Err(MongrelError::InvalidCredentials {
9040                username: previous.username,
9041            }),
9042        }
9043    }
9044
9045    /// Number of security-catalog disk reloads performed by this open handle.
9046    /// Initial open reads are excluded.
9047    pub fn security_catalog_disk_read_count(&self) -> u64 {
9048        self.security_catalog_disk_reads.load(Ordering::Relaxed)
9049    }
9050
9051    /// Convert a credentialless database to a credentialed one: create the
9052    /// first admin user, set `require_auth = true`, and cache the admin
9053    /// principal on this handle so subsequent operations on the same handle
9054    /// continue to work. After this call, the database can only be reopened
9055    /// via `open_with_credentials` / `open_encrypted_with_credentials`.
9056    ///
9057    /// Refuses if the database already has `require_auth = true`. This is
9058    /// the conversion path for existing databases; for fresh databases,
9059    /// `create_with_credentials` sets everything up atomically.
9060    ///
9061    /// See `docs/15-credential-enforcement.md`.
9062    pub fn enable_auth(&self, admin_username: &str, admin_password: &str) -> Result<()> {
9063        if self.shared {
9064            // Fail closed (spec §4.6): one shared handle must not flip the
9065            // core into an enforcement mode the other handles cannot observe.
9066            // Stage 1A shared cores stay credentialless; auth-mode transitions
9067            // require an exclusive `Database` owner. Per-handle principals
9068            // land with Stage 1D sessions.
9069            return Err(MongrelError::Conflict(
9070                "enable_auth requires an exclusive Database owner; shared cores reject \
9071                 auth-mode transitions"
9072                    .into(),
9073            ));
9074        }
9075        let password_hash =
9076            crate::auth::hash_password(admin_password).map_err(MongrelError::Other)?;
9077        let scram_sha_256 =
9078            crate::auth::scram_verifier(admin_password).map_err(MongrelError::Other)?;
9079        let mysql_caching_sha2 =
9080            crate::auth::MysqlCachingSha2Verifier::from_password(admin_password);
9081        let _ddl = self.ddl_lock.lock();
9082        let _security_write = self.security_write()?;
9083        let _commit = self.commit_lock.lock();
9084        let epoch = self.epoch.bump_assigned();
9085        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
9086        let mut next_catalog = self.catalog.read().clone();
9087        if next_catalog.require_auth {
9088            return Err(MongrelError::InvalidArgument(
9089                "database already has require_auth enabled".into(),
9090            ));
9091        }
9092        if next_catalog
9093            .users
9094            .iter()
9095            .any(|u| u.username == admin_username)
9096        {
9097            return Err(MongrelError::InvalidArgument(format!(
9098                "user {admin_username:?} already exists"
9099            )));
9100        }
9101        next_catalog.next_user_id = next_catalog.next_user_id.max(1);
9102        let id = next_catalog.next_user_id;
9103        next_catalog.next_user_id = id
9104            .checked_add(1)
9105            .ok_or_else(|| MongrelError::Full("user-id namespace exhausted".into()))?;
9106        next_catalog.users.push(crate::auth::UserEntry {
9107            id,
9108            username: admin_username.to_string(),
9109            password_hash,
9110            scram_sha_256: Some(scram_sha_256),
9111            mysql_caching_sha2: Some(mysql_caching_sha2),
9112            roles: Vec::new(),
9113            is_admin: true,
9114            created_epoch: epoch.0,
9115        });
9116        next_catalog.require_auth = true;
9117        advance_security_version(&mut next_catalog)?;
9118        next_catalog.db_epoch = epoch.0;
9119        let publish = self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, None);
9120        // Cache the admin principal on this handle + update the shared auth
9121        // state whenever rename published, even if directory fsync was
9122        // inconclusive.
9123        if publish.is_ok() || matches!(&publish, Err(MongrelError::CommitOutcomeUnknown { .. })) {
9124            let principal = crate::auth::Principal {
9125                user_id: id,
9126                created_epoch: epoch.0,
9127                username: admin_username.to_string(),
9128                is_admin: true,
9129                roles: Vec::new(),
9130                permissions: Vec::new(),
9131            };
9132            *self.principal.write() = Some(principal.clone());
9133            self.auth_state.set_principal(Some(principal));
9134        }
9135        publish
9136    }
9137
9138    /// Disable `require_auth` on this database, reverting it to credentialless
9139    /// mode. This is the **recovery** path — it requires the handle to already
9140    /// be open (and therefore already authenticated if `require_auth` was on).
9141    ///
9142    /// After this call, the database can be reopened with plain
9143    /// [`open`](Self::open) / [`open_encrypted`](Self::open_encrypted) without
9144    /// credentials. All existing users and roles are preserved in the catalog
9145    /// (so `require_auth` can be re-enabled without recreating them), but they
9146    /// are no longer consulted for enforcement.
9147    ///
9148    /// For true **offline** recovery (when credentials are lost and no
9149    /// authenticated handle is available), the caller opens the database
9150    /// directly via the catalog file (filesystem access required) and calls
9151    /// this method — see the CLI's `auth disable-offline` command.
9152    ///
9153    /// See `docs/15-credential-enforcement.md` §4.7.
9154    pub fn disable_auth(&self) -> Result<()> {
9155        let _ddl = self.ddl_lock.lock();
9156        let _security_write = self.security_write()?;
9157        let _commit = self.commit_lock.lock();
9158        let epoch = self.epoch.bump_assigned();
9159        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
9160        let mut next_catalog = self.catalog.read().clone();
9161        if !next_catalog.require_auth {
9162            return Err(MongrelError::InvalidArgument(
9163                "database does not have require_auth enabled".into(),
9164            ));
9165        }
9166        next_catalog.require_auth = false;
9167        advance_security_version(&mut next_catalog)?;
9168        next_catalog.db_epoch = epoch.0;
9169        let publish = self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, None);
9170        // Clear the cached principal — enforcement is now off.
9171        if publish.is_ok() || matches!(&publish, Err(MongrelError::CommitOutcomeUnknown { .. })) {
9172            *self.principal.write() = None;
9173        }
9174        publish
9175    }
9176
9177    /// Enforcement check: if the catalog has `require_auth = true`, verify
9178    /// that the cached principal satisfies `perm`. Called by every
9179    /// enforcement point (DDL, admin, maintenance, and — in Phase 2 —
9180    /// Table/Transaction/MongrelSession operations).
9181    ///
9182    /// On a credentialless database this is a no-op (`Ok(())`).
9183    pub fn require(&self, perm: &crate::auth::Permission) -> Result<()> {
9184        self.ensure_owner_process()?;
9185        if self.read_only && !matches!(perm, crate::auth::Permission::Select { .. }) {
9186            return Err(MongrelError::ReadOnlyReplica);
9187        }
9188        if self.principal.read().is_some() {
9189            self.refresh_principal().map_err(|error| match error {
9190                MongrelError::InvalidCredentials { .. } => MongrelError::AuthRequired,
9191                error => error,
9192            })?;
9193        }
9194        if !self.catalog.read().require_auth {
9195            return Ok(());
9196        }
9197        let guard = self.principal.read();
9198        let p = guard.as_ref().ok_or(MongrelError::AuthRequired)?;
9199        if p.has_permission(perm) {
9200            Ok(())
9201        } else {
9202            Err(MongrelError::PermissionDenied {
9203                required: perm.clone(),
9204                principal: p.username.clone(),
9205            })
9206        }
9207    }
9208
9209    /// Convenience: enforce a table-level permission (`Select`/`Insert`/
9210    /// `Update`/`Delete`) by table name. Used by the Transaction layer and
9211    /// other callers that know the operation kind + table name but don't want
9212    /// to construct the full `Permission` enum value themselves.
9213    pub fn require_table(
9214        &self,
9215        table: &str,
9216        perm: crate::auth_state::RequiredPermission,
9217    ) -> Result<()> {
9218        self.require(&perm.into_permission(table))
9219    }
9220
9221    pub fn triggers(&self) -> Vec<StoredTrigger> {
9222        self.catalog
9223            .read()
9224            .triggers
9225            .iter()
9226            .map(|t| t.trigger.clone())
9227            .collect()
9228    }
9229
9230    pub fn trigger(&self, name: &str) -> Option<StoredTrigger> {
9231        self.catalog
9232            .read()
9233            .triggers
9234            .iter()
9235            .find(|t| t.trigger.name == name)
9236            .map(|t| t.trigger.clone())
9237    }
9238
9239    pub fn create_trigger(&self, trigger: StoredTrigger) -> Result<StoredTrigger> {
9240        self.create_trigger_inner(trigger, None, None)
9241    }
9242
9243    pub fn create_trigger_controlled<F>(
9244        &self,
9245        trigger: StoredTrigger,
9246        mut before_publish: F,
9247    ) -> Result<StoredTrigger>
9248    where
9249        F: FnMut() -> Result<()>,
9250    {
9251        self.create_trigger_inner(trigger, None, Some(&mut before_publish))
9252    }
9253
9254    pub fn create_trigger_as_controlled<F>(
9255        &self,
9256        trigger: StoredTrigger,
9257        principal: Option<&crate::auth::Principal>,
9258        mut before_publish: F,
9259    ) -> Result<StoredTrigger>
9260    where
9261        F: FnMut() -> Result<()>,
9262    {
9263        self.create_trigger_inner(trigger, principal, Some(&mut before_publish))
9264    }
9265
9266    fn create_trigger_inner(
9267        &self,
9268        mut trigger: StoredTrigger,
9269        principal: Option<&crate::auth::Principal>,
9270        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
9271    ) -> Result<StoredTrigger> {
9272        let command = crate::catalog_cmds::CatalogCommand::CreateTrigger {
9273            trigger: trigger.clone(),
9274        };
9275        self.require_for(
9276            principal,
9277            &crate::catalog_cmds::required_permission(&command),
9278        )?;
9279        let _g = self.ddl_lock.lock();
9280        let _security_write = self.security_write()?;
9281        self.require_for(
9282            principal,
9283            &crate::catalog_cmds::required_permission(&command),
9284        )?;
9285        trigger.validate()?;
9286        self.validate_trigger_references(&trigger)
9287            .map_err(trigger_validation_error)?;
9288        // S1F-001: validation runs pure against the current catalog first so
9289        // failures (duplicate name, unknown target) surface before an epoch is
9290        // consumed, exactly like the legacy pre-bump checks.
9291        crate::catalog_cmds::apply(&self.catalog.read(), &command)?;
9292        let commit_lock = Arc::clone(&self.commit_lock);
9293        let _c = commit_lock.lock();
9294        let epoch = self.epoch.bump_assigned();
9295        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
9296        trigger.created_epoch = epoch.0;
9297        trigger.updated_epoch = epoch.0;
9298        let command = crate::catalog_cmds::CatalogCommand::CreateTrigger {
9299            trigger: trigger.clone(),
9300        };
9301        let mut next_catalog = self.catalog.read().clone();
9302        self.apply_catalog_command_to(&mut next_catalog, command)?;
9303        next_catalog.db_epoch = epoch.0;
9304        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
9305        Ok(trigger)
9306    }
9307
9308    pub fn create_or_replace_trigger(&self, trigger: StoredTrigger) -> Result<StoredTrigger> {
9309        self.create_or_replace_trigger_inner(trigger, None, None)
9310    }
9311
9312    pub fn create_or_replace_trigger_controlled<F>(
9313        &self,
9314        trigger: StoredTrigger,
9315        mut before_publish: F,
9316    ) -> Result<StoredTrigger>
9317    where
9318        F: FnMut() -> Result<()>,
9319    {
9320        self.create_or_replace_trigger_inner(trigger, None, Some(&mut before_publish))
9321    }
9322
9323    pub fn create_or_replace_trigger_as_controlled<F>(
9324        &self,
9325        trigger: StoredTrigger,
9326        principal: Option<&crate::auth::Principal>,
9327        mut before_publish: F,
9328    ) -> Result<StoredTrigger>
9329    where
9330        F: FnMut() -> Result<()>,
9331    {
9332        self.create_or_replace_trigger_inner(trigger, principal, Some(&mut before_publish))
9333    }
9334
9335    fn create_or_replace_trigger_inner(
9336        &self,
9337        trigger: StoredTrigger,
9338        principal: Option<&crate::auth::Principal>,
9339        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
9340    ) -> Result<StoredTrigger> {
9341        let command = crate::catalog_cmds::CatalogCommand::ReplaceTrigger {
9342            trigger: trigger.clone(),
9343        };
9344        self.require_for(
9345            principal,
9346            &crate::catalog_cmds::required_permission(&command),
9347        )?;
9348        let _g = self.ddl_lock.lock();
9349        let _security_write = self.security_write()?;
9350        self.require_for(
9351            principal,
9352            &crate::catalog_cmds::required_permission(&command),
9353        )?;
9354        trigger.validate()?;
9355        self.validate_trigger_references(&trigger)
9356            .map_err(trigger_validation_error)?;
9357        // S1F-001: validation runs pure against the current catalog first so
9358        // structural failures surface before an epoch is consumed, exactly
9359        // like the legacy pre-bump checks.
9360        crate::catalog_cmds::apply(&self.catalog.read(), &command)?;
9361        let commit_lock = Arc::clone(&self.commit_lock);
9362        let _c = commit_lock.lock();
9363        let epoch = self.epoch.bump_assigned();
9364        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
9365        let mut next_catalog = self.catalog.read().clone();
9366        // Resolve the replacement image against the candidate catalog: the
9367        // engine stamps version/epochs from the commit epoch (preserving the
9368        // original created_epoch on replacement), then the command record
9369        // carries that resolved image.
9370        let replaced = match next_catalog
9371            .triggers
9372            .iter()
9373            .position(|t| t.trigger.name == trigger.name)
9374        {
9375            Some(idx) => next_catalog.triggers[idx]
9376                .trigger
9377                .replaced(trigger.clone(), epoch.0)?,
9378            None => {
9379                let mut next = trigger;
9380                next.created_epoch = epoch.0;
9381                next.updated_epoch = epoch.0;
9382                next
9383            }
9384        };
9385        let command = crate::catalog_cmds::CatalogCommand::ReplaceTrigger {
9386            trigger: replaced.clone(),
9387        };
9388        self.apply_catalog_command_to(&mut next_catalog, command)?;
9389        next_catalog.db_epoch = epoch.0;
9390        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
9391        Ok(replaced)
9392    }
9393
9394    pub fn drop_trigger(&self, name: &str) -> Result<()> {
9395        self.drop_trigger_with_epoch(name).map(|_| ())
9396    }
9397
9398    /// Drop one trigger and return the exact catalog publication epoch.
9399    pub fn drop_trigger_with_epoch(&self, name: &str) -> Result<Epoch> {
9400        self.drop_triggers_with_epoch(&[name.to_string()])
9401    }
9402
9403    pub fn drop_trigger_with_epoch_controlled<F>(
9404        &self,
9405        name: &str,
9406        before_publish: F,
9407    ) -> Result<Epoch>
9408    where
9409        F: FnMut() -> Result<()>,
9410    {
9411        self.drop_triggers_with_epoch_controlled(&[name.to_string()], before_publish)
9412    }
9413
9414    /// Atomically drop several triggers in one catalog publication.
9415    pub fn drop_triggers_with_epoch(&self, names: &[String]) -> Result<Epoch> {
9416        self.drop_triggers_with_epoch_inner(names, None, None)
9417    }
9418
9419    pub fn drop_triggers_with_epoch_controlled<F>(
9420        &self,
9421        names: &[String],
9422        mut before_publish: F,
9423    ) -> Result<Epoch>
9424    where
9425        F: FnMut() -> Result<()>,
9426    {
9427        self.drop_triggers_with_epoch_inner(names, None, Some(&mut before_publish))
9428    }
9429
9430    pub fn drop_triggers_with_epoch_as_controlled<F>(
9431        &self,
9432        names: &[String],
9433        principal: Option<&crate::auth::Principal>,
9434        mut before_publish: F,
9435    ) -> Result<Epoch>
9436    where
9437        F: FnMut() -> Result<()>,
9438    {
9439        self.drop_triggers_with_epoch_inner(names, principal, Some(&mut before_publish))
9440    }
9441
9442    fn drop_triggers_with_epoch_inner(
9443        &self,
9444        names: &[String],
9445        principal: Option<&crate::auth::Principal>,
9446        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
9447    ) -> Result<Epoch> {
9448        if names.is_empty() {
9449            return Err(MongrelError::InvalidArgument(
9450                "at least one trigger name is required".into(),
9451            ));
9452        }
9453        let commands: Vec<crate::catalog_cmds::CatalogCommand> = names
9454            .iter()
9455            .map(|name| crate::catalog_cmds::CatalogCommand::DropTrigger { name: name.clone() })
9456            .collect();
9457        for command in &commands {
9458            self.require_for(
9459                principal,
9460                &crate::catalog_cmds::required_permission(command),
9461            )?;
9462        }
9463        let _g = self.ddl_lock.lock();
9464        let _security_write = self.security_write()?;
9465        for command in &commands {
9466            self.require_for(
9467                principal,
9468                &crate::catalog_cmds::required_permission(command),
9469            )?;
9470        }
9471        // S1F-001: every name's command validates pure against the current
9472        // catalog first so a missing trigger surfaces before an epoch is
9473        // consumed, exactly like the legacy pre-bump check. A batch drop then
9474        // records one command per name (the emitting-layer expansion rule).
9475        {
9476            let cat = self.catalog.read();
9477            for command in &commands {
9478                crate::catalog_cmds::apply(&cat, command)?;
9479            }
9480        }
9481        let commit_lock = Arc::clone(&self.commit_lock);
9482        let _c = commit_lock.lock();
9483        let epoch = self.epoch.bump_assigned();
9484        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
9485        let mut next_catalog = self.catalog.read().clone();
9486        for command in commands {
9487            self.apply_catalog_command_to(&mut next_catalog, command)?;
9488        }
9489        next_catalog.db_epoch = epoch.0;
9490        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
9491        Ok(epoch)
9492    }
9493
9494    pub fn external_tables(&self) -> Vec<ExternalTableEntry> {
9495        self.catalog.read().external_tables.clone()
9496    }
9497
9498    pub fn external_table(&self, name: &str) -> Option<ExternalTableEntry> {
9499        self.catalog
9500            .read()
9501            .external_tables
9502            .iter()
9503            .find(|t| t.name == name)
9504            .cloned()
9505    }
9506
9507    pub fn create_external_table(&self, entry: ExternalTableEntry) -> Result<ExternalTableEntry> {
9508        self.create_external_table_inner(entry, None)
9509    }
9510
9511    pub fn create_external_table_controlled<F>(
9512        &self,
9513        entry: ExternalTableEntry,
9514        mut before_publish: F,
9515    ) -> Result<ExternalTableEntry>
9516    where
9517        F: FnMut() -> Result<()>,
9518    {
9519        self.create_external_table_inner(entry, Some(&mut before_publish))
9520    }
9521
9522    fn create_external_table_inner(
9523        &self,
9524        mut entry: ExternalTableEntry,
9525        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
9526    ) -> Result<ExternalTableEntry> {
9527        self.require(&crate::auth::Permission::Ddl)?;
9528        let _g = self.ddl_lock.lock();
9529        let _security_write = self.security_write()?;
9530        self.require(&crate::auth::Permission::Ddl)?;
9531        entry.validate()?;
9532        {
9533            let cat = self.catalog.read();
9534            if cat.live(&entry.name).is_some()
9535                || cat.external_tables.iter().any(|t| t.name == entry.name)
9536            {
9537                return Err(MongrelError::InvalidArgument(format!(
9538                    "table {:?} already exists",
9539                    entry.name
9540                )));
9541            }
9542        }
9543        let commit_lock = Arc::clone(&self.commit_lock);
9544        let _c = commit_lock.lock();
9545        // A prior durable drop may have left connector state behind if its
9546        // cleanup failed or the process crashed. Never let a new table with
9547        // the same name inherit that stale state.
9548        crate::durable_file::create_directory(&self.root.join(VTAB_DIR))?;
9549        crate::durable_file::remove_directory_all(&self.root.join(VTAB_DIR).join(&entry.name))?;
9550        let epoch = self.epoch.bump_assigned();
9551        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
9552        entry.created_epoch = epoch.0;
9553        let mut next_catalog = self.catalog.read().clone();
9554        next_catalog.external_tables.push(entry.clone());
9555        next_catalog.db_epoch = epoch.0;
9556        self.publish_catalog_candidate_with_prelude(
9557            next_catalog,
9558            epoch,
9559            &mut _epoch_guard,
9560            before_publish,
9561            vec![(
9562                EXTERNAL_TABLE_ID,
9563                crate::wal::Op::Ddl(crate::wal::DdlOp::ResetExternalTableState {
9564                    name: entry.name.clone(),
9565                    generation_epoch: epoch.0,
9566                }),
9567            )],
9568        )?;
9569        Ok(entry)
9570    }
9571
9572    pub fn drop_external_table(&self, name: &str) -> Result<()> {
9573        self.drop_external_table_with_epoch(name).map(|_| ())
9574    }
9575
9576    /// Drop an external table and return the exact catalog publication epoch.
9577    pub fn drop_external_table_with_epoch(&self, name: &str) -> Result<Epoch> {
9578        self.drop_external_table_with_epoch_inner(name, None)
9579    }
9580
9581    pub fn drop_external_table_with_epoch_controlled<F>(
9582        &self,
9583        name: &str,
9584        mut before_publish: F,
9585    ) -> Result<Epoch>
9586    where
9587        F: FnMut() -> Result<()>,
9588    {
9589        self.drop_external_table_with_epoch_inner(name, Some(&mut before_publish))
9590    }
9591
9592    fn drop_external_table_with_epoch_inner(
9593        &self,
9594        name: &str,
9595        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
9596    ) -> Result<Epoch> {
9597        self.require(&crate::auth::Permission::Ddl)?;
9598        let _g = self.ddl_lock.lock();
9599        let _security_write = self.security_write()?;
9600        self.require(&crate::auth::Permission::Ddl)?;
9601        let commit_lock = Arc::clone(&self.commit_lock);
9602        let _c = commit_lock.lock();
9603        let epoch = self.epoch.bump_assigned();
9604        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
9605        let mut next_catalog = self.catalog.read().clone();
9606        let before = next_catalog.external_tables.len();
9607        next_catalog.external_tables.retain(|t| t.name != name);
9608        if next_catalog.external_tables.len() == before {
9609            return Err(MongrelError::NotFound(format!(
9610                "external table {name:?} not found"
9611            )));
9612        }
9613        next_catalog.db_epoch = epoch.0;
9614        self.publish_catalog_candidate_with_prelude(
9615            next_catalog,
9616            epoch,
9617            &mut _epoch_guard,
9618            before_publish,
9619            vec![(
9620                EXTERNAL_TABLE_ID,
9621                crate::wal::Op::Ddl(crate::wal::DdlOp::ResetExternalTableState {
9622                    name: name.to_string(),
9623                    generation_epoch: epoch.0,
9624                }),
9625            )],
9626        )?;
9627        let state_dir = self.root.join(VTAB_DIR).join(name);
9628        if let Err(error) = crate::durable_file::remove_directory_all(&state_dir) {
9629            return Err(MongrelError::DurableCommit {
9630                epoch: epoch.0,
9631                message: format!(
9632                    "external table was dropped but connector-state cleanup failed: {error}"
9633                ),
9634            });
9635        }
9636        Ok(epoch)
9637    }
9638
9639    pub fn commit_external_table_state(&self, name: &str, state: &[u8]) -> Result<Epoch> {
9640        let txn_id = self.alloc_txn_id()?;
9641        let (principal, catalog_bound) = self.transaction_principal_snapshot();
9642        self.commit_transaction_with_external_states(
9643            txn_id,
9644            self.epoch.visible(),
9645            Vec::new(),
9646            vec![(name.to_string(), state.to_vec())],
9647            Vec::new(),
9648            principal,
9649            catalog_bound,
9650            None,
9651            crate::txn::TxnCommitContext::internal(),
9652        )
9653        .map(|(epoch, _)| epoch)
9654    }
9655
9656    pub fn trigger_config(&self) -> TriggerConfig {
9657        use std::sync::atomic::Ordering;
9658        TriggerConfig {
9659            recursive_triggers: self.trigger_recursive.load(Ordering::Relaxed),
9660            max_depth: self.trigger_max_depth.load(Ordering::Relaxed),
9661            max_loop_iterations: self.trigger_max_loop_iterations.load(Ordering::Relaxed),
9662        }
9663    }
9664
9665    pub fn set_trigger_config(&self, config: TriggerConfig) -> Result<()> {
9666        use std::sync::atomic::Ordering;
9667        if config.max_depth == 0 {
9668            return Err(MongrelError::InvalidArgument(
9669                "trigger max_depth must be greater than 0".into(),
9670            ));
9671        }
9672        self.trigger_recursive
9673            .store(config.recursive_triggers, Ordering::Relaxed);
9674        self.trigger_max_depth
9675            .store(config.max_depth, Ordering::Relaxed);
9676        self.trigger_max_loop_iterations
9677            .store(config.max_loop_iterations, Ordering::Relaxed);
9678        Ok(())
9679    }
9680
9681    pub fn set_recursive_triggers(&self, recursive: bool) {
9682        use std::sync::atomic::Ordering;
9683        self.trigger_recursive.store(recursive, Ordering::Relaxed);
9684    }
9685
9686    /// Subscribe to ephemeral SQL NOTIFY messages. Durable row changes use
9687    /// [`Self::change_events_since`], with [`Self::subscribe_change_commits`]
9688    /// as a low-latency wake-up.
9689    pub fn subscribe_changes(&self) -> tokio::sync::broadcast::Receiver<ChangeEvent> {
9690        self.notify.subscribe()
9691    }
9692
9693    pub fn subscribe_change_commits(&self) -> tokio::sync::broadcast::Receiver<()> {
9694        self.change_wake.subscribe()
9695    }
9696
9697    /// Reconstruct committed row changes from the retained shared WAL. Event
9698    /// ids are stable `<commit_epoch>:<operation_index>` pairs. A caller that
9699    /// resumes before the oldest retained commit receives `gap = true` and
9700    /// must rebootstrap instead of silently skipping changes.
9701    pub fn change_events_since(&self, last_event_id: Option<&str>) -> Result<CdcBatch> {
9702        let control = crate::ExecutionControl::new(None);
9703        self.change_events_since_controlled(last_event_id, &control)
9704    }
9705
9706    /// Reconstruct committed changes with cooperative cancellation and bounds.
9707    pub fn change_events_since_controlled(
9708        &self,
9709        last_event_id: Option<&str>,
9710        control: &crate::ExecutionControl,
9711    ) -> Result<CdcBatch> {
9712        use crate::wal::Op;
9713
9714        control.checkpoint()?;
9715        let resume = match last_event_id {
9716            Some(id) => {
9717                let (epoch, index) = id.split_once(':').ok_or_else(|| {
9718                    MongrelError::InvalidArgument(format!(
9719                        "invalid CDC event id {id:?}; expected <epoch>:<index>"
9720                    ))
9721                })?;
9722                Some((
9723                    epoch.parse::<u64>().map_err(|error| {
9724                        MongrelError::InvalidArgument(format!("invalid CDC epoch: {error}"))
9725                    })?,
9726                    index.parse::<u32>().map_err(|error| {
9727                        MongrelError::InvalidArgument(format!("invalid CDC index: {error}"))
9728                    })?,
9729                ))
9730            }
9731            None => None,
9732        };
9733
9734        let mut wal = self.shared_wal.lock();
9735        wal.group_sync()?;
9736        let wal_dek = crate::encryption::wal_dek_for(self.kek.as_deref());
9737        let records = crate::wal::SharedWal::replay_with_dek_controlled(
9738            &self.root,
9739            wal_dek.as_ref(),
9740            control,
9741            CDC_MAX_WAL_RECORDS,
9742            CDC_MAX_WAL_REPLAY_BYTES,
9743        )?;
9744        drop(wal);
9745        control.checkpoint()?;
9746
9747        let mut commits: HashMap<u64, (u64, Vec<crate::wal::AddedRun>)> = HashMap::new();
9748        let mut spilled_payloads: HashMap<(u64, u64), Vec<&[u8]>> = HashMap::new();
9749        for (index, record) in records.iter().enumerate() {
9750            if index % 256 == 0 {
9751                control.checkpoint()?;
9752            }
9753            if let Op::TxnCommit { epoch, added_runs } = &record.op {
9754                commits.insert(record.txn_id, (*epoch, added_runs.clone()));
9755            }
9756            if let Op::SpilledRows { table_id, rows } = &record.op {
9757                spilled_payloads
9758                    .entry((record.txn_id, *table_id))
9759                    .or_default()
9760                    .push(rows);
9761            }
9762        }
9763        let earliest_epoch = commits.values().map(|(epoch, _)| *epoch).min();
9764        let current_epoch = self.epoch.committed().0;
9765        let retention_floor = crate::replication::replication_wal_floor(&self.root)?;
9766        let gap = resume.is_some_and(|(epoch, _)| {
9767            retention_floor != 0 && epoch <= retention_floor && epoch <= current_epoch
9768        });
9769        if gap {
9770            return Ok(CdcBatch {
9771                events: Vec::new(),
9772                current_epoch,
9773                earliest_epoch,
9774                gap: true,
9775            });
9776        }
9777
9778        let table_names: HashMap<u64, String> = self
9779            .catalog
9780            .read()
9781            .tables
9782            .iter()
9783            .map(|entry| (entry.table_id, entry.name.clone()))
9784            .collect();
9785        let mut before_images: HashMap<(u64, u64, u64), crate::memtable::Row> = HashMap::new();
9786        let mut retained_bytes = 0_usize;
9787        for (index, record) in records.iter().enumerate() {
9788            if index % 256 == 0 {
9789                control.checkpoint()?;
9790            }
9791            if !commits.contains_key(&record.txn_id) {
9792                continue;
9793            }
9794            let Op::BeforeImage {
9795                table_id,
9796                row_id,
9797                row,
9798            } = &record.op
9799            else {
9800                continue;
9801            };
9802            if row.len() > CDC_MAX_INLINE_PAYLOAD_BYTES {
9803                return Err(MongrelError::ResourceLimitExceeded {
9804                    resource: "CDC before-image bytes",
9805                    requested: row.len(),
9806                    limit: CDC_MAX_INLINE_PAYLOAD_BYTES,
9807                });
9808            }
9809            let before: crate::memtable::Row = bincode::deserialize(row)?;
9810            if before_images.len() >= CDC_MAX_ROWS {
9811                return Err(MongrelError::ResourceLimitExceeded {
9812                    resource: "CDC before-image rows",
9813                    requested: before_images.len().saturating_add(1),
9814                    limit: CDC_MAX_ROWS,
9815                });
9816            }
9817            charge_cdc_bytes(
9818                &mut retained_bytes,
9819                cdc_row_storage_bytes(&before),
9820                "CDC retained bytes",
9821            )?;
9822            before_images.insert((record.txn_id, *table_id, row_id.0), before);
9823        }
9824        let mut operation_indices: HashMap<u64, u32> = HashMap::new();
9825        let mut events = Vec::new();
9826        let mut decoded_rows = before_images.len();
9827        for (record_index, record) in records.iter().enumerate() {
9828            if record_index % 256 == 0 {
9829                control.checkpoint()?;
9830            }
9831            let Some((commit_epoch, _)) = commits.get(&record.txn_id) else {
9832                continue;
9833            };
9834            let event = match &record.op {
9835                Op::Put { table_id, rows } => {
9836                    if rows.len() > CDC_MAX_INLINE_PAYLOAD_BYTES {
9837                        return Err(MongrelError::ResourceLimitExceeded {
9838                            resource: "CDC inline row bytes",
9839                            requested: rows.len(),
9840                            limit: CDC_MAX_INLINE_PAYLOAD_BYTES,
9841                        });
9842                    }
9843                    let rows: Vec<crate::memtable::Row> = bincode::deserialize(rows)?;
9844                    decoded_rows = decoded_rows.saturating_add(rows.len());
9845                    if decoded_rows > CDC_MAX_ROWS {
9846                        return Err(MongrelError::ResourceLimitExceeded {
9847                            resource: "CDC decoded rows",
9848                            requested: decoded_rows,
9849                            limit: CDC_MAX_ROWS,
9850                        });
9851                    }
9852                    let event_bytes = cdc_rows_json_bytes(&rows).saturating_add(512);
9853                    let mut peak_bytes = retained_bytes;
9854                    charge_cdc_bytes(&mut peak_bytes, event_bytes, "CDC retained event bytes")?;
9855                    let data = serde_json::to_value(rows)
9856                        .map_err(|error| MongrelError::Other(format!("CDC JSON: {error}")))?;
9857                    Some((*table_id, "put", data, event_bytes))
9858                }
9859                Op::Delete { table_id, row_ids } => {
9860                    let before = row_ids
9861                        .iter()
9862                        .filter_map(|row_id| {
9863                            before_images
9864                                .get(&(record.txn_id, *table_id, row_id.0))
9865                                .cloned()
9866                        })
9867                        .collect::<Vec<_>>();
9868                    let event_bytes = cdc_rows_json_bytes(&before)
9869                        .saturating_add(
9870                            row_ids
9871                                .len()
9872                                .saturating_mul(std::mem::size_of::<serde_json::Value>()),
9873                        )
9874                        .saturating_add(512);
9875                    let mut peak_bytes = retained_bytes;
9876                    charge_cdc_bytes(&mut peak_bytes, event_bytes, "CDC retained event bytes")?;
9877                    Some((
9878                        *table_id,
9879                        "delete",
9880                        serde_json::json!({
9881                            "row_ids": row_ids.iter().map(|row_id| row_id.0).collect::<Vec<_>>(),
9882                            "before": before,
9883                        }),
9884                        event_bytes,
9885                    ))
9886                }
9887                Op::TruncateTable { table_id } => {
9888                    Some((*table_id, "truncate", serde_json::Value::Null, 512))
9889                }
9890                _ => None,
9891            };
9892            if let Some((table_id, op, data, event_bytes)) = event {
9893                let index = operation_indices.entry(record.txn_id).or_insert(0);
9894                let event_position = (*commit_epoch, *index);
9895                *index = index.saturating_add(1);
9896                if resume.is_some_and(|position| event_position <= position) {
9897                    continue;
9898                }
9899                if events.len() >= CDC_MAX_EVENTS {
9900                    return Err(MongrelError::ResourceLimitExceeded {
9901                        resource: "CDC events",
9902                        requested: events.len().saturating_add(1),
9903                        limit: CDC_MAX_EVENTS,
9904                    });
9905                }
9906                charge_cdc_bytes(&mut retained_bytes, event_bytes, "CDC retained event bytes")?;
9907                events.push(ChangeEvent {
9908                    id: Some(format!("{}:{}", event_position.0, event_position.1)),
9909                    channel: "changes".into(),
9910                    table_id: Some(table_id),
9911                    table: table_names.get(&table_id).cloned().unwrap_or_default(),
9912                    op: op.into(),
9913                    epoch: *commit_epoch,
9914                    txn_id: Some(record.txn_id),
9915                    message: None,
9916                    data: Some(data),
9917                });
9918            }
9919            if let Op::TxnCommit { added_runs, .. } = &record.op {
9920                for run in added_runs {
9921                    control.checkpoint()?;
9922                    let index = operation_indices.entry(record.txn_id).or_insert(0);
9923                    let event_position = (*commit_epoch, *index);
9924                    *index = index.saturating_add(1);
9925                    if resume.is_some_and(|position| event_position <= position) {
9926                        continue;
9927                    }
9928                    let mut rows = if let Some(payloads) =
9929                        spilled_payloads.get(&(record.txn_id, run.table_id))
9930                    {
9931                        let mut rows = Vec::new();
9932                        for payload in payloads {
9933                            control.checkpoint()?;
9934                            if payload.len() > CDC_MAX_INLINE_PAYLOAD_BYTES {
9935                                return Err(MongrelError::ResourceLimitExceeded {
9936                                    resource: "CDC spilled row bytes",
9937                                    requested: payload.len(),
9938                                    limit: CDC_MAX_INLINE_PAYLOAD_BYTES,
9939                                });
9940                            }
9941                            let chunk: Vec<crate::memtable::Row> = bincode::deserialize(payload)?;
9942                            if decoded_rows
9943                                .saturating_add(rows.len())
9944                                .saturating_add(chunk.len())
9945                                > CDC_MAX_ROWS
9946                            {
9947                                return Err(MongrelError::ResourceLimitExceeded {
9948                                    resource: "CDC decoded rows",
9949                                    requested: decoded_rows
9950                                        .saturating_add(rows.len())
9951                                        .saturating_add(chunk.len()),
9952                                    limit: CDC_MAX_ROWS,
9953                                });
9954                            }
9955                            rows.extend(chunk);
9956                        }
9957                        rows
9958                    } else {
9959                        let Some(handle) = self.tables.read().get(&run.table_id).cloned() else {
9960                            return Ok(CdcBatch {
9961                                events: Vec::new(),
9962                                current_epoch,
9963                                earliest_epoch,
9964                                gap: true,
9965                            });
9966                        };
9967                        let table = handle.lock();
9968                        let mut reader = match table.open_reader(run.run_id) {
9969                            Ok(reader) => reader,
9970                            Err(_) => {
9971                                return Ok(CdcBatch {
9972                                    events: Vec::new(),
9973                                    current_epoch,
9974                                    earliest_epoch,
9975                                    gap: true,
9976                                })
9977                            }
9978                        };
9979                        let remaining = CDC_MAX_ROWS.saturating_sub(decoded_rows);
9980                        let rows = reader.all_rows_controlled(control, remaining)?;
9981                        drop(reader);
9982                        drop(table);
9983                        rows
9984                    };
9985                    for row in &mut rows {
9986                        row.committed_epoch = Epoch(*commit_epoch);
9987                    }
9988                    decoded_rows = decoded_rows.saturating_add(rows.len());
9989                    let event_bytes = cdc_rows_json_bytes(&rows).saturating_add(768);
9990                    charge_cdc_bytes(&mut retained_bytes, event_bytes, "CDC retained event bytes")?;
9991                    if events.len() >= CDC_MAX_EVENTS {
9992                        return Err(MongrelError::ResourceLimitExceeded {
9993                            resource: "CDC events",
9994                            requested: events.len().saturating_add(1),
9995                            limit: CDC_MAX_EVENTS,
9996                        });
9997                    }
9998                    events.push(ChangeEvent {
9999                        id: Some(format!("{}:{}", event_position.0, event_position.1)),
10000                        channel: "changes".into(),
10001                        table_id: Some(run.table_id),
10002                        table: table_names.get(&run.table_id).cloned().unwrap_or_default(),
10003                        op: "put_run".into(),
10004                        epoch: *commit_epoch,
10005                        txn_id: Some(record.txn_id),
10006                        message: None,
10007                        data: Some(serde_json::json!({
10008                            "run_id": run.run_id.to_string(),
10009                            "row_count": run.row_count,
10010                            "min_row_id": run.min_row_id,
10011                            "max_row_id": run.max_row_id,
10012                            "rows": rows,
10013                        })),
10014                    });
10015                }
10016            }
10017        }
10018        control.checkpoint()?;
10019        Ok(CdcBatch {
10020            events,
10021            current_epoch,
10022            earliest_epoch,
10023            gap: false,
10024        })
10025    }
10026
10027    /// Publish a notification message on a named channel. Reaches all active
10028    /// subscribers (daemon `/events`, application listeners).
10029    pub fn notify(&self, channel: &str, message: Option<String>) {
10030        let _ = self.notify.send(ChangeEvent {
10031            id: None,
10032            channel: channel.to_string(),
10033            table_id: None,
10034            table: String::new(),
10035            op: "notify".into(),
10036            epoch: self.epoch.visible().0,
10037            txn_id: None,
10038            message,
10039            data: None,
10040        });
10041    }
10042
10043    pub fn call_procedure(
10044        &self,
10045        name: &str,
10046        args: HashMap<String, crate::Value>,
10047    ) -> Result<ProcedureCallResult> {
10048        self.call_procedure_as(name, args, None)
10049    }
10050
10051    pub fn call_procedure_as(
10052        &self,
10053        name: &str,
10054        args: HashMap<String, crate::Value>,
10055        principal: Option<&crate::auth::Principal>,
10056    ) -> Result<ProcedureCallResult> {
10057        let control = crate::ExecutionControl::new(None);
10058        self.call_procedure_as_controlled(name, args, principal, &control, || true)
10059    }
10060
10061    /// Execute only the exact procedure revision previously authorized by the
10062    /// caller. A dropped or replaced definition fails closed.
10063    #[doc(hidden)]
10064    pub fn call_procedure_as_bound(
10065        &self,
10066        expected: &StoredProcedure,
10067        args: HashMap<String, crate::Value>,
10068        principal: Option<&crate::auth::Principal>,
10069    ) -> Result<ProcedureCallResult> {
10070        self.require_for(principal, &crate::auth::Permission::All)?;
10071        let procedure = self.procedure(&expected.name).ok_or_else(|| {
10072            MongrelError::NotFound(format!("procedure {:?} not found", expected.name))
10073        })?;
10074        if &procedure != expected {
10075            return Err(MongrelError::Conflict(format!(
10076                "procedure {:?} changed after request authorization",
10077                expected.name
10078            )));
10079        }
10080        let control = crate::ExecutionControl::new(None);
10081        self.execute_procedure_as_controlled(procedure, args, principal, &control, || true)
10082    }
10083
10084    /// Execute a procedure with cooperative cancellation during preparation.
10085    /// `before_commit` runs after every procedure step has succeeded and
10086    /// immediately before a write procedure commits. Returning `false` aborts
10087    /// the transaction without publishing it.
10088    #[doc(hidden)]
10089    pub fn call_procedure_as_controlled<F>(
10090        &self,
10091        name: &str,
10092        args: HashMap<String, crate::Value>,
10093        principal: Option<&crate::auth::Principal>,
10094        control: &crate::ExecutionControl,
10095        before_commit: F,
10096    ) -> Result<ProcedureCallResult>
10097    where
10098        F: FnOnce() -> bool,
10099    {
10100        // v1 requires ALL to call procedures on a require_auth database; a
10101        // finer SECURITY DEFINER-style marker is a future extension (spec §9
10102        // decision 1).
10103        self.require_for(principal, &crate::auth::Permission::All)?;
10104        let procedure = self
10105            .procedure(name)
10106            .ok_or_else(|| MongrelError::NotFound(format!("procedure {name:?} not found")))?;
10107        self.execute_procedure_as_controlled(procedure, args, principal, control, before_commit)
10108    }
10109
10110    fn execute_procedure_as_controlled<F>(
10111        &self,
10112        procedure: StoredProcedure,
10113        args: HashMap<String, crate::Value>,
10114        principal: Option<&crate::auth::Principal>,
10115        control: &crate::ExecutionControl,
10116        before_commit: F,
10117    ) -> Result<ProcedureCallResult>
10118    where
10119        F: FnOnce() -> bool,
10120    {
10121        let args = bind_procedure_args(&procedure, args)?;
10122        let has_writes = procedure.body.steps.iter().any(ProcedureStep::is_write);
10123        let mut outputs: HashMap<String, ProcedureCallOutput> = HashMap::new();
10124        if has_writes {
10125            let mut tx = self.begin_as(principal.cloned());
10126            let run = (|| {
10127                for (step_index, step) in procedure.body.steps.iter().enumerate() {
10128                    if step_index % 256 == 0 {
10129                        control.checkpoint()?;
10130                    }
10131                    let output = self.execute_procedure_step(
10132                        step,
10133                        &args,
10134                        &outputs,
10135                        Some(&mut tx),
10136                        principal,
10137                        Some(control),
10138                    )?;
10139                    outputs.insert(step.id().to_string(), output);
10140                }
10141                control.checkpoint()?;
10142                eval_return_output(&procedure.body.return_value, &args, &outputs)
10143            })();
10144            match run {
10145                Ok(output) => {
10146                    control.checkpoint()?;
10147                    if !before_commit() {
10148                        tx.rollback();
10149                        return Err(MongrelError::Cancelled);
10150                    }
10151                    let epoch = tx.commit()?.0;
10152                    Ok(ProcedureCallResult {
10153                        epoch: Some(epoch),
10154                        output,
10155                    })
10156                }
10157                Err(e) => {
10158                    tx.rollback();
10159                    Err(e)
10160                }
10161            }
10162        } else {
10163            for (step_index, step) in procedure.body.steps.iter().enumerate() {
10164                if step_index % 256 == 0 {
10165                    control.checkpoint()?;
10166                }
10167                let output = self.execute_procedure_step(
10168                    step,
10169                    &args,
10170                    &outputs,
10171                    None,
10172                    principal,
10173                    Some(control),
10174                )?;
10175                outputs.insert(step.id().to_string(), output);
10176            }
10177            control.checkpoint()?;
10178            Ok(ProcedureCallResult {
10179                epoch: None,
10180                output: eval_return_output(&procedure.body.return_value, &args, &outputs)?,
10181            })
10182        }
10183    }
10184
10185    fn execute_procedure_step(
10186        &self,
10187        step: &ProcedureStep,
10188        args: &HashMap<String, crate::Value>,
10189        outputs: &HashMap<String, ProcedureCallOutput>,
10190        tx: Option<&mut crate::txn::Transaction<'_>>,
10191        principal: Option<&crate::auth::Principal>,
10192        control: Option<&crate::ExecutionControl>,
10193    ) -> Result<ProcedureCallOutput> {
10194        if let Some(control) = control {
10195            control.checkpoint()?;
10196        }
10197        match step {
10198            ProcedureStep::NativeQuery {
10199                table,
10200                conditions,
10201                projection,
10202                limit,
10203                ..
10204            } => {
10205                let mut q = crate::Query::new();
10206                for condition in conditions {
10207                    q = q.and(eval_condition(condition, args, outputs)?);
10208                }
10209                let fallback_control = crate::ExecutionControl::new(None);
10210                let query_control = control.unwrap_or(&fallback_control);
10211                let mut rows = self.query_for_principal_controlled(
10212                    table,
10213                    &q,
10214                    projection.as_deref(),
10215                    principal,
10216                    false,
10217                    query_control,
10218                )?;
10219                if let Some(limit) = limit {
10220                    rows.truncate(*limit);
10221                }
10222                let mut output = Vec::with_capacity(rows.len());
10223                for (row_index, row) in rows.into_iter().enumerate() {
10224                    if row_index % 256 == 0 {
10225                        if let Some(control) = control {
10226                            control.checkpoint()?;
10227                        }
10228                    }
10229                    output.push(ProcedureCallRow {
10230                        row_id: Some(row.row_id),
10231                        columns: row.columns,
10232                    });
10233                }
10234                Ok(ProcedureCallOutput::Rows(output))
10235            }
10236            ProcedureStep::Put {
10237                table,
10238                cells,
10239                returning,
10240                ..
10241            } => {
10242                let tx = tx.ok_or_else(|| {
10243                    MongrelError::InvalidArgument(
10244                        "write procedure step requires a transaction".into(),
10245                    )
10246                })?;
10247                let cells = eval_cells(cells, args, outputs)?;
10248                if *returning {
10249                    let out = tx.put_returning(table, cells)?;
10250                    Ok(ProcedureCallOutput::Row(ProcedureCallRow {
10251                        row_id: None,
10252                        columns: out.row.columns.into_iter().collect(),
10253                    }))
10254                } else {
10255                    tx.put(table, cells)?;
10256                    Ok(ProcedureCallOutput::Null)
10257                }
10258            }
10259            ProcedureStep::Upsert {
10260                table,
10261                cells,
10262                update_cells,
10263                returning,
10264                ..
10265            } => {
10266                let tx = tx.ok_or_else(|| {
10267                    MongrelError::InvalidArgument(
10268                        "write procedure step requires a transaction".into(),
10269                    )
10270                })?;
10271                let cells = eval_cells(cells, args, outputs)?;
10272                let action = match update_cells {
10273                    Some(update_cells) => {
10274                        crate::UpsertAction::DoUpdate(eval_cells(update_cells, args, outputs)?)
10275                    }
10276                    None => crate::UpsertAction::DoNothing,
10277                };
10278                let out = tx.upsert(table, cells, action)?;
10279                if *returning {
10280                    Ok(ProcedureCallOutput::Row(ProcedureCallRow {
10281                        row_id: None,
10282                        columns: out.row.columns.into_iter().collect(),
10283                    }))
10284                } else {
10285                    Ok(ProcedureCallOutput::Null)
10286                }
10287            }
10288            ProcedureStep::DeleteByPk { table, pk, .. } => {
10289                let tx = tx.ok_or_else(|| {
10290                    MongrelError::InvalidArgument(
10291                        "write procedure step requires a transaction".into(),
10292                    )
10293                })?;
10294                let pk = eval_value(pk, args, outputs)?;
10295                let handle = self.table(table)?;
10296                let row_id = handle.lock().lookup_pk(&pk.encode_key()).ok_or_else(|| {
10297                    MongrelError::NotFound("procedure delete_by_pk target not found".into())
10298                })?;
10299                tx.delete(table, row_id)?;
10300                Ok(ProcedureCallOutput::Scalar(crate::Value::Bool(true)))
10301            }
10302            ProcedureStep::DeleteRows { .. } => Err(MongrelError::InvalidArgument(
10303                "DeleteRows procedure step is not supported by the core executor yet".into(),
10304            )),
10305            ProcedureStep::SqlQuery { .. } => Err(MongrelError::InvalidArgument(
10306                "SqlQuery procedure step must be executed by mongreldb-query".into(),
10307            )),
10308        }
10309    }
10310
10311    fn validate_procedure_references(&self, procedure: &StoredProcedure) -> Result<()> {
10312        let cat = self.catalog.read();
10313        for step in &procedure.body.steps {
10314            let Some(table_name) = step.table() else {
10315                continue;
10316            };
10317            let schema = &cat
10318                .live(table_name)
10319                .ok_or_else(|| {
10320                    MongrelError::InvalidArgument(format!(
10321                        "procedure {:?} references unknown table {table_name:?}",
10322                        procedure.name
10323                    ))
10324                })?
10325                .schema;
10326            match step {
10327                ProcedureStep::NativeQuery {
10328                    conditions,
10329                    projection,
10330                    ..
10331                } => {
10332                    for condition in conditions {
10333                        validate_condition_columns(condition, schema)?;
10334                    }
10335                    if let Some(projection) = projection {
10336                        for id in projection {
10337                            validate_column_id(*id, schema)?;
10338                        }
10339                    }
10340                }
10341                ProcedureStep::Put { cells, .. } => {
10342                    for cell in cells {
10343                        validate_column_id(cell.column_id, schema)?;
10344                    }
10345                }
10346                ProcedureStep::Upsert {
10347                    cells,
10348                    update_cells,
10349                    ..
10350                } => {
10351                    for cell in cells {
10352                        validate_column_id(cell.column_id, schema)?;
10353                    }
10354                    if let Some(update_cells) = update_cells {
10355                        for cell in update_cells {
10356                            validate_column_id(cell.column_id, schema)?;
10357                        }
10358                    }
10359                }
10360                ProcedureStep::DeleteByPk { .. } => {
10361                    if schema.primary_key().is_none() {
10362                        return Err(MongrelError::InvalidArgument(format!(
10363                            "procedure {:?} references DeleteByPk on table {table_name:?} without a primary key",
10364                            procedure.name
10365                        )));
10366                    }
10367                }
10368                ProcedureStep::DeleteRows { .. } | ProcedureStep::SqlQuery { .. } => {}
10369            }
10370        }
10371        Ok(())
10372    }
10373
10374    fn validate_trigger_references(&self, trigger: &StoredTrigger) -> Result<()> {
10375        let cat = self.catalog.read();
10376        let target_schema = match &trigger.target {
10377            TriggerTarget::Table(target_name) => cat
10378                .live(target_name)
10379                .ok_or_else(|| {
10380                    MongrelError::InvalidArgument(format!(
10381                        "trigger {:?} references unknown target table {target_name:?}",
10382                        trigger.name
10383                    ))
10384                })?
10385                .schema
10386                .clone(),
10387            TriggerTarget::View(_) => Schema {
10388                columns: trigger.target_columns.clone(),
10389                ..Schema::default()
10390            },
10391        };
10392        for col in &trigger.update_of {
10393            if target_schema.column(col).is_none() {
10394                return Err(MongrelError::InvalidArgument(format!(
10395                    "trigger {:?} UPDATE OF references unknown column {col:?}",
10396                    trigger.name
10397                )));
10398            }
10399        }
10400        if let Some(expr) = &trigger.when {
10401            validate_trigger_expr(expr, &target_schema, trigger.event)?;
10402        }
10403        let mut select_schemas: HashMap<String, &Schema> = HashMap::new();
10404        for step in &trigger.program.steps {
10405            if matches!(step, TriggerStep::SetNew { .. }) && trigger.timing != TriggerTiming::Before
10406            {
10407                return Err(MongrelError::InvalidArgument(
10408                    "SetNew trigger steps are only valid in BEFORE triggers".into(),
10409                ));
10410            }
10411            validate_trigger_step(
10412                step,
10413                &cat,
10414                &target_schema,
10415                trigger.event,
10416                &mut select_schemas,
10417            )?;
10418        }
10419        Ok(())
10420    }
10421
10422    /// Begin a new transaction reading at the current visible epoch.
10423    pub fn begin(&self) -> crate::txn::Transaction<'_> {
10424        self.begin_with_isolation(crate::txn::IsolationLevel::default())
10425    }
10426
10427    fn transaction_principal_snapshot(&self) -> (Option<crate::auth::Principal>, bool) {
10428        let principal = self.principal.read().clone();
10429        let catalog_bound = principal
10430            .as_ref()
10431            .is_some_and(|principal| principal.user_id != 0);
10432        (principal, catalog_bound)
10433    }
10434
10435    pub fn begin_as(
10436        &self,
10437        principal: Option<crate::auth::Principal>,
10438    ) -> crate::txn::Transaction<'_> {
10439        let catalog_bound = principal
10440            .as_ref()
10441            .is_some_and(|principal| principal.user_id != 0);
10442        let txn_id = self.alloc_txn_id();
10443        let read = self.visible_snapshot();
10444        crate::txn::Transaction::new(self, txn_id, read, crate::txn::IsolationLevel::default())
10445            .with_principal(principal, catalog_bound)
10446    }
10447
10448    /// Begin a transaction with a specific isolation level.
10449    pub fn begin_with_isolation(
10450        &self,
10451        level: crate::txn::IsolationLevel,
10452    ) -> crate::txn::Transaction<'_> {
10453        let txn_id = self.alloc_txn_id();
10454        // Every level pins the current visible epoch + HLC at begin; ReadCommitted
10455        // re-pins per statement inside the transaction (S1B-002 / P0.5).
10456        let read = self.visible_snapshot();
10457        let (principal, catalog_bound) = self.transaction_principal_snapshot();
10458        crate::txn::Transaction::new(self, txn_id, read, level)
10459            .with_principal(principal, catalog_bound)
10460    }
10461
10462    /// Begin a transaction whose trigger programs may route external-table DML
10463    /// through an application/query-layer module bridge.
10464    pub fn begin_with_external_trigger_bridge<'a>(
10465        &'a self,
10466        bridge: &'a dyn ExternalTriggerBridge,
10467    ) -> crate::txn::Transaction<'a> {
10468        let txn_id = self.alloc_txn_id();
10469        let read = self.visible_snapshot();
10470        let (principal, catalog_bound) = self.transaction_principal_snapshot();
10471        crate::txn::Transaction::new(self, txn_id, read, crate::txn::IsolationLevel::default())
10472            .with_external_trigger_bridge(bridge)
10473            .with_principal(principal, catalog_bound)
10474    }
10475
10476    pub fn begin_with_external_trigger_bridge_as<'a>(
10477        &'a self,
10478        bridge: &'a dyn ExternalTriggerBridge,
10479        principal: Option<crate::auth::Principal>,
10480    ) -> crate::txn::Transaction<'a> {
10481        let catalog_bound = principal
10482            .as_ref()
10483            .is_some_and(|principal| principal.user_id != 0);
10484        let txn_id = self.alloc_txn_id();
10485        let read = self.visible_snapshot();
10486        crate::txn::Transaction::new(self, txn_id, read, crate::txn::IsolationLevel::default())
10487            .with_external_trigger_bridge(bridge)
10488            .with_principal(principal, catalog_bound)
10489    }
10490
10491    /// Run `f` in a transaction; commit on `Ok`, rollback on `Err`.
10492    pub fn transaction<T>(
10493        &self,
10494        f: impl FnOnce(&mut crate::txn::Transaction) -> Result<T>,
10495    ) -> Result<T> {
10496        let mut tx = self.begin();
10497        match f(&mut tx) {
10498            Ok(out) => {
10499                tx.commit()?;
10500                Ok(out)
10501            }
10502            Err(e) => {
10503                tx.rollback();
10504                Err(e)
10505            }
10506        }
10507    }
10508
10509    pub fn transaction_with_row_ids<T>(
10510        &self,
10511        f: impl FnOnce(&mut crate::txn::Transaction) -> Result<T>,
10512    ) -> Result<(T, Vec<RowId>)> {
10513        let mut tx = self.begin();
10514        match f(&mut tx) {
10515            Ok(output) => {
10516                let (_, row_ids) = tx.commit_with_row_ids()?;
10517                Ok((output, row_ids))
10518            }
10519            Err(error) => {
10520                tx.rollback();
10521                Err(error)
10522            }
10523        }
10524    }
10525
10526    pub fn transaction_for_current_principal<T>(
10527        &self,
10528        f: impl FnOnce(&mut crate::txn::Transaction) -> Result<T>,
10529    ) -> Result<T> {
10530        if self.principal.read().is_some() {
10531            self.refresh_principal()?;
10532        }
10533        let mut transaction = self.begin_as(self.principal.read().clone());
10534        match f(&mut transaction) {
10535            Ok(output) => {
10536                transaction.commit()?;
10537                Ok(output)
10538            }
10539            Err(error) => {
10540                transaction.rollback();
10541                Err(error)
10542            }
10543        }
10544    }
10545
10546    pub fn transaction_for_current_principal_with_epoch<T>(
10547        &self,
10548        f: impl FnOnce(&mut crate::txn::Transaction) -> Result<T>,
10549    ) -> Result<(Epoch, T)> {
10550        if self.principal.read().is_some() {
10551            self.refresh_principal()?;
10552        }
10553        let mut transaction = self.begin_as(self.principal.read().clone());
10554        match f(&mut transaction) {
10555            Ok(output) => {
10556                let epoch = transaction.commit()?;
10557                Ok((epoch, output))
10558            }
10559            Err(error) => {
10560                transaction.rollback();
10561                Err(error)
10562            }
10563        }
10564    }
10565
10566    pub fn transaction_with_row_ids_for_current_principal<T>(
10567        &self,
10568        f: impl FnOnce(&mut crate::txn::Transaction) -> Result<T>,
10569    ) -> Result<(T, Vec<RowId>)> {
10570        if self.principal.read().is_some() {
10571            self.refresh_principal()?;
10572        }
10573        let mut transaction = self.begin_as(self.principal.read().clone());
10574        match f(&mut transaction) {
10575            Ok(output) => {
10576                let (_, row_ids) = transaction.commit_with_row_ids()?;
10577                Ok((output, row_ids))
10578            }
10579            Err(error) => {
10580                transaction.rollback();
10581                Err(error)
10582            }
10583        }
10584    }
10585
10586    /// Run `f` in a transaction with an external-trigger bridge; commit on
10587    /// `Ok`, rollback on `Err`.
10588    pub fn transaction_with_external_trigger_bridge<'a, T>(
10589        &'a self,
10590        bridge: &'a dyn ExternalTriggerBridge,
10591        f: impl FnOnce(&mut crate::txn::Transaction) -> Result<T>,
10592    ) -> Result<T> {
10593        let mut tx = self.begin_with_external_trigger_bridge(bridge);
10594        match f(&mut tx) {
10595            Ok(out) => {
10596                tx.commit()?;
10597                Ok(out)
10598            }
10599            Err(e) => {
10600                tx.rollback();
10601                Err(e)
10602            }
10603        }
10604    }
10605
10606    pub fn transaction_with_external_trigger_bridge_as<'a, T>(
10607        &'a self,
10608        bridge: &'a dyn ExternalTriggerBridge,
10609        principal: Option<crate::auth::Principal>,
10610        f: impl FnOnce(&mut crate::txn::Transaction) -> Result<T>,
10611    ) -> Result<T> {
10612        let mut tx = self.begin_with_external_trigger_bridge_as(bridge, principal);
10613        match f(&mut tx) {
10614            Ok(output) => {
10615                tx.commit()?;
10616                Ok(output)
10617            }
10618            Err(error) => {
10619                tx.rollback();
10620                Err(error)
10621            }
10622        }
10623    }
10624
10625    /// Register a txn in `ActiveTxns` (spec §9.2, review fix #12). Called from
10626    /// `Transaction::new` so registration happens **before** any read.
10627    pub(crate) fn register_active(&self, epoch: Epoch) -> crate::txn::ActiveTxnGuard<'_> {
10628        self.active_txns.register(epoch)
10629    }
10630
10631    fn fill_auto_increment_for_staging(
10632        &self,
10633        txn_id: u64,
10634        staging: &mut [(u64, crate::txn::Staged)],
10635        control: Option<&crate::ExecutionControl>,
10636    ) -> Result<()> {
10637        let mut puts_by_table: HashMap<u64, Vec<usize>> = HashMap::new();
10638        for (index, (table_id, staged)) in staging.iter().enumerate() {
10639            commit_prepare_checkpoint(control, index)?;
10640            if matches!(staged, crate::txn::Staged::Put(_)) {
10641                puts_by_table.entry(*table_id).or_default().push(index);
10642            }
10643        }
10644
10645        // S1B-003: sequence allocation serializes on one Exclusive barrier per
10646        // staged table whose puts actually ALLOCATE an auto-increment value
10647        // (absent/Null column — explicit values only advance the counter and
10648        // must not serialize), held until the transaction ends so allocated
10649        // values map monotonically onto commit order. The barrier is acquired
10650        // before the table lock (never the reverse).
10651        {
10652            let mut barrier_tables: Vec<u64> = puts_by_table
10653                .iter()
10654                .filter(|(table_id, indexes)| {
10655                    indexes.iter().any(|index| {
10656                        matches!(
10657                            &staging[*index].1,
10658                            crate::txn::Staged::Put(cells)
10659                                if self.table_auto_inc_would_allocate(**table_id, cells)
10660                        )
10661                    })
10662                })
10663                .map(|(table_id, _)| *table_id)
10664                .collect();
10665            barrier_tables.sort_unstable();
10666            for table_id in barrier_tables {
10667                self.acquire_txn_lock(
10668                    txn_id,
10669                    crate::locks::LockKey::sequence_barrier(&format!("auto_inc:{table_id}")),
10670                    crate::locks::LockMode::Exclusive,
10671                    control,
10672                )?;
10673            }
10674        }
10675
10676        let tables = self.tables.read();
10677        for (table_index, (table_id, indexes)) in puts_by_table.into_iter().enumerate() {
10678            commit_prepare_checkpoint(control, table_index)?;
10679            if let Some(handle) = tables.get(&table_id) {
10680                #[cfg(test)]
10681                AUTO_INCREMENT_TABLE_LOCKS.with(|count| count.set(count.get() + 1));
10682                let mut t = handle.lock();
10683                for (fill_index, index) in indexes.into_iter().enumerate() {
10684                    commit_prepare_checkpoint(control, fill_index)?;
10685                    if let crate::txn::Staged::Put(cells) = &mut staging[index].1 {
10686                        t.fill_auto_inc(cells)?;
10687                    }
10688                }
10689            }
10690        }
10691        Ok(())
10692    }
10693
10694    fn expand_table_triggers(
10695        &self,
10696        txn_id: u64,
10697        staging: &mut Vec<(u64, crate::txn::Staged)>,
10698        read_epoch: Epoch,
10699        external_trigger_bridge: Option<&dyn ExternalTriggerBridge>,
10700        external_states: &mut Vec<(String, Vec<u8>)>,
10701        control: Option<&crate::ExecutionControl>,
10702    ) -> Result<()> {
10703        commit_prepare_checkpoint(control, 0)?;
10704        let mut external_writes = Vec::new();
10705        let config = self.trigger_config();
10706        if config.recursive_triggers {
10707            let chunk = std::mem::take(staging);
10708            let stacks = vec![Vec::new(); chunk.len()];
10709            *staging = self.expand_trigger_chunk(
10710                txn_id,
10711                chunk,
10712                stacks,
10713                read_epoch,
10714                0,
10715                config.max_depth,
10716                &mut external_writes,
10717                &config,
10718                control,
10719            )?;
10720            self.apply_external_trigger_writes(
10721                external_writes,
10722                external_trigger_bridge,
10723                external_states,
10724                staging,
10725                control,
10726            )?;
10727            return Ok(());
10728        }
10729
10730        let mut expansion =
10731            self.expand_table_triggers_once(staging, read_epoch, None, &config, control)?;
10732        if !expansion.before.is_empty() {
10733            let mut final_staging = expansion.before;
10734            final_staging.extend(filter_ignored_staging(
10735                std::mem::take(staging),
10736                &expansion.ignored_indices,
10737            ));
10738            *staging = final_staging;
10739        } else if !expansion.ignored_indices.is_empty() {
10740            *staging = filter_ignored_staging(std::mem::take(staging), &expansion.ignored_indices);
10741        }
10742        staging.append(&mut expansion.after);
10743        external_writes.append(&mut expansion.before_external);
10744        external_writes.append(&mut expansion.after_external);
10745        self.apply_external_trigger_writes(
10746            external_writes,
10747            external_trigger_bridge,
10748            external_states,
10749            staging,
10750            control,
10751        )?;
10752        Ok(())
10753    }
10754
10755    #[allow(clippy::too_many_arguments)]
10756    fn expand_trigger_chunk(
10757        &self,
10758        txn_id: u64,
10759        mut chunk: Vec<(u64, crate::txn::Staged)>,
10760        stacks: Vec<Vec<String>>,
10761        read_epoch: Epoch,
10762        depth: u32,
10763        max_depth: u32,
10764        external_writes: &mut Vec<ExternalTriggerWrite>,
10765        config: &TriggerConfig,
10766        control: Option<&crate::ExecutionControl>,
10767    ) -> Result<Vec<(u64, crate::txn::Staged)>> {
10768        if chunk.is_empty() {
10769            return Ok(Vec::new());
10770        }
10771        commit_prepare_checkpoint(control, 0)?;
10772        self.fill_auto_increment_for_staging(txn_id, &mut chunk, control)?;
10773        let expansion = self.expand_table_triggers_once(
10774            &mut chunk,
10775            read_epoch,
10776            Some(&stacks),
10777            config,
10778            control,
10779        )?;
10780        if depth >= max_depth && (!expansion.before.is_empty() || !expansion.after.is_empty()) {
10781            let stack = expansion
10782                .before_stacks
10783                .first()
10784                .or_else(|| expansion.after_stacks.first())
10785                .cloned()
10786                .unwrap_or_default();
10787            return Err(MongrelError::TriggerValidation(format!(
10788                "trigger recursion exceeded max depth {max_depth}; trigger stack: {}",
10789                Self::format_trigger_stack(&stack)
10790            )));
10791        }
10792
10793        let mut out = Vec::new();
10794        external_writes.extend(expansion.before_external);
10795        out.extend(self.expand_trigger_chunk(
10796            txn_id,
10797            expansion.before,
10798            expansion.before_stacks,
10799            read_epoch,
10800            depth + 1,
10801            max_depth,
10802            external_writes,
10803            config,
10804            control,
10805        )?);
10806        out.extend(filter_ignored_staging(chunk, &expansion.ignored_indices));
10807        external_writes.extend(expansion.after_external);
10808        out.extend(self.expand_trigger_chunk(
10809            txn_id,
10810            expansion.after,
10811            expansion.after_stacks,
10812            read_epoch,
10813            depth + 1,
10814            max_depth,
10815            external_writes,
10816            config,
10817            control,
10818        )?);
10819        Ok(out)
10820    }
10821
10822    fn apply_external_trigger_writes(
10823        &self,
10824        writes: Vec<ExternalTriggerWrite>,
10825        bridge: Option<&dyn ExternalTriggerBridge>,
10826        external_states: &mut Vec<(String, Vec<u8>)>,
10827        staging: &mut Vec<(u64, crate::txn::Staged)>,
10828        control: Option<&crate::ExecutionControl>,
10829    ) -> Result<()> {
10830        if writes.is_empty() {
10831            return Ok(());
10832        }
10833        let bridge = bridge.ok_or_else(|| {
10834            MongrelError::TriggerValidation(
10835                "trigger program wrote an external table, but this transaction has no external trigger bridge".into(),
10836            )
10837        })?;
10838        for (write_index, write) in writes.into_iter().enumerate() {
10839            commit_prepare_checkpoint(control, write_index)?;
10840            let table = write.table().to_string();
10841            let entry = self.external_table(&table).ok_or_else(|| {
10842                MongrelError::NotFound(format!("external table {table:?} not found"))
10843            })?;
10844            let base_state = current_external_state_bytes(&self.root, external_states, &table)?;
10845            let result = bridge.apply_trigger_external_write(&entry, base_state, write)?;
10846            external_states.push((table, result.state));
10847            for (base_index, base_write) in result.base_writes.into_iter().enumerate() {
10848                commit_prepare_checkpoint(control, base_index)?;
10849                match base_write {
10850                    ExternalTriggerBaseWrite::Put { table, cells } => {
10851                        let table_id = self.table_id(&table)?;
10852                        staging.push((table_id, crate::txn::Staged::Put(cells)));
10853                    }
10854                    ExternalTriggerBaseWrite::Delete { table, row_id } => {
10855                        let table_id = self.table_id(&table)?;
10856                        staging.push((table_id, crate::txn::Staged::Delete(row_id)));
10857                    }
10858                }
10859            }
10860        }
10861        dedup_external_states_in_place(external_states);
10862        Ok(())
10863    }
10864
10865    fn expand_table_triggers_once(
10866        &self,
10867        staging: &mut Vec<(u64, crate::txn::Staged)>,
10868        read_epoch: Epoch,
10869        trigger_stacks: Option<&[Vec<String>]>,
10870        config: &TriggerConfig,
10871        control: Option<&crate::ExecutionControl>,
10872    ) -> Result<TriggerExpansion> {
10873        commit_prepare_checkpoint(control, 0)?;
10874        let triggers: Vec<StoredTrigger> = self
10875            .catalog
10876            .read()
10877            .triggers
10878            .iter()
10879            .filter(|entry| {
10880                entry.trigger.enabled
10881                    && matches!(
10882                        entry.trigger.timing,
10883                        TriggerTiming::Before | TriggerTiming::After
10884                    )
10885                    && matches!(entry.trigger.target, TriggerTarget::Table(_))
10886            })
10887            .map(|entry| entry.trigger.clone())
10888            .collect();
10889        if triggers.is_empty() || staging.is_empty() {
10890            return Ok(TriggerExpansion::default());
10891        }
10892
10893        let before_triggers = triggers
10894            .iter()
10895            .filter(|trigger| trigger.timing == TriggerTiming::Before)
10896            .cloned()
10897            .collect::<Vec<_>>();
10898        let after_triggers = triggers
10899            .iter()
10900            .filter(|trigger| trigger.timing == TriggerTiming::After)
10901            .cloned()
10902            .collect::<Vec<_>>();
10903
10904        let mut before_added = Vec::new();
10905        let mut before_stacks = Vec::new();
10906        let mut before_external = Vec::new();
10907        let mut ignored_indices = std::collections::BTreeSet::new();
10908        if !before_triggers.is_empty() {
10909            let before_events =
10910                self.trigger_events_for_staging(staging, read_epoch, trigger_stacks, control)?;
10911            let mut out = TriggerProgramOutput {
10912                added: &mut before_added,
10913                added_stacks: &mut before_stacks,
10914                added_external: &mut before_external,
10915                ignored_indices: &mut ignored_indices,
10916            };
10917            self.execute_triggers_for_events(
10918                &before_triggers,
10919                &before_events,
10920                Some(staging),
10921                &mut out,
10922                config,
10923                read_epoch,
10924                control,
10925            )?;
10926        }
10927
10928        let after_events = if after_triggers.is_empty() {
10929            Vec::new()
10930        } else {
10931            self.trigger_events_for_staging(staging, read_epoch, trigger_stacks, control)?
10932                .into_iter()
10933                .filter(|event| {
10934                    !event
10935                        .op_indices
10936                        .iter()
10937                        .any(|idx| ignored_indices.contains(idx))
10938                })
10939                .collect()
10940        };
10941
10942        let mut after_added = Vec::new();
10943        let mut after_stacks = Vec::new();
10944        let mut after_external = Vec::new();
10945        let mut out = TriggerProgramOutput {
10946            added: &mut after_added,
10947            added_stacks: &mut after_stacks,
10948            added_external: &mut after_external,
10949            ignored_indices: &mut ignored_indices,
10950        };
10951        self.execute_triggers_for_events(
10952            &after_triggers,
10953            &after_events,
10954            None,
10955            &mut out,
10956            config,
10957            read_epoch,
10958            control,
10959        )?;
10960        Ok(TriggerExpansion {
10961            before: before_added,
10962            before_stacks,
10963            before_external,
10964            after: after_added,
10965            after_stacks,
10966            after_external,
10967            ignored_indices,
10968        })
10969    }
10970
10971    #[allow(clippy::too_many_arguments)]
10972    fn execute_triggers_for_events(
10973        &self,
10974        triggers: &[StoredTrigger],
10975        events: &[WriteEvent],
10976        mut staging: Option<&mut Vec<(u64, crate::txn::Staged)>>,
10977        out: &mut TriggerProgramOutput<'_>,
10978        config: &TriggerConfig,
10979        read_epoch: Epoch,
10980        control: Option<&crate::ExecutionControl>,
10981    ) -> Result<()> {
10982        let mut checkpoint_index = 0_usize;
10983        for event in events {
10984            for trigger in triggers {
10985                commit_prepare_checkpoint(control, checkpoint_index)?;
10986                checkpoint_index += 1;
10987                if event
10988                    .op_indices
10989                    .iter()
10990                    .any(|idx| out.ignored_indices.contains(idx))
10991                {
10992                    break;
10993                }
10994                let matches = {
10995                    let cat = self.catalog.read();
10996                    trigger_matches_event(trigger, event, &cat)?
10997                };
10998                if !matches {
10999                    continue;
11000                }
11001                if let Some(when) = &trigger.when {
11002                    if !eval_trigger_expr(when, event)? {
11003                        continue;
11004                    }
11005                }
11006                let trigger_stack = Self::trigger_stack_with(&event.trigger_stack, &trigger.name);
11007                if event.trigger_stack.iter().any(|name| name == &trigger.name) {
11008                    return Err(MongrelError::TriggerValidation(format!(
11009                        "trigger recursion cycle detected; trigger stack: {}",
11010                        Self::format_trigger_stack(&trigger_stack)
11011                    )));
11012                }
11013                let outcome = match staging.as_mut() {
11014                    Some(staging) => self.execute_trigger_program(
11015                        trigger,
11016                        event,
11017                        Some(&mut **staging),
11018                        out,
11019                        &trigger_stack,
11020                        config,
11021                        read_epoch,
11022                        control,
11023                    )?,
11024                    None => self.execute_trigger_program(
11025                        trigger,
11026                        event,
11027                        None,
11028                        out,
11029                        &trigger_stack,
11030                        config,
11031                        read_epoch,
11032                        control,
11033                    )?,
11034                };
11035                if outcome == TriggerProgramOutcome::Ignore {
11036                    out.ignored_indices.extend(event.op_indices.iter().copied());
11037                    break;
11038                }
11039            }
11040        }
11041        Ok(())
11042    }
11043
11044    fn trigger_events_for_staging(
11045        &self,
11046        staging: &[(u64, crate::txn::Staged)],
11047        read_epoch: Epoch,
11048        trigger_stacks: Option<&[Vec<String>]>,
11049        control: Option<&crate::ExecutionControl>,
11050    ) -> Result<Vec<WriteEvent>> {
11051        use crate::txn::Staged;
11052        use std::collections::{HashMap, VecDeque};
11053
11054        let snapshot = self.snapshot_for_epoch(read_epoch);
11055        let cat = self.catalog.read();
11056        let mut table_names = HashMap::new();
11057        let mut table_schemas = HashMap::new();
11058        for entry in cat
11059            .tables
11060            .iter()
11061            .filter(|entry| matches!(entry.state, TableState::Live))
11062        {
11063            table_names.insert(entry.table_id, entry.name.clone());
11064            table_schemas.insert(entry.table_id, entry.schema.clone());
11065        }
11066        drop(cat);
11067
11068        let mut old_rows: HashMap<usize, TriggerRowImage> = HashMap::new();
11069        let mut delete_by_key: HashMap<(u64, Vec<u8>), VecDeque<usize>> = HashMap::new();
11070        let mut put_by_key: HashMap<(u64, Vec<u8>), VecDeque<usize>> = HashMap::new();
11071
11072        for (idx, (table_id, staged)) in staging.iter().enumerate() {
11073            commit_prepare_checkpoint(control, idx)?;
11074            let Some(schema) = table_schemas.get(table_id) else {
11075                continue;
11076            };
11077            let Some(pk) = schema.primary_key() else {
11078                continue;
11079            };
11080            match staged {
11081                Staged::Delete(row_id) => {
11082                    let handle = self.table_by_id(*table_id)?;
11083                    let Some(row) = handle.lock().get(*row_id, snapshot) else {
11084                        continue;
11085                    };
11086                    let Some(pk_value) = row.columns.get(&pk.id) else {
11087                        continue;
11088                    };
11089                    old_rows.insert(idx, TriggerRowImage::from_row(row.clone()));
11090                    delete_by_key
11091                        .entry((*table_id, pk_value.encode_key()))
11092                        .or_default()
11093                        .push_back(idx);
11094                }
11095                Staged::Put(cells) => {
11096                    if let Some((_, value)) = cells.iter().find(|(id, _)| *id == pk.id) {
11097                        put_by_key
11098                            .entry((*table_id, value.encode_key()))
11099                            .or_default()
11100                            .push_back(idx);
11101                    }
11102                }
11103                Staged::Update { row_id, .. } => {
11104                    let handle = self.table_by_id(*table_id)?;
11105                    let row = handle.lock().get(*row_id, snapshot);
11106                    if let Some(row) = row {
11107                        old_rows.insert(idx, TriggerRowImage::from_row(row));
11108                    }
11109                }
11110                Staged::Truncate => {}
11111            }
11112        }
11113
11114        let mut paired_delete = std::collections::HashSet::new();
11115        let mut paired_put = std::collections::HashSet::new();
11116        let mut events = Vec::new();
11117
11118        for (pair_index, (key, deletes)) in delete_by_key.iter_mut().enumerate() {
11119            commit_prepare_checkpoint(control, pair_index)?;
11120            let Some(puts) = put_by_key.get_mut(key) else {
11121                continue;
11122            };
11123            while let (Some(delete_idx), Some(put_idx)) = (deletes.pop_front(), puts.pop_front()) {
11124                paired_delete.insert(delete_idx);
11125                paired_put.insert(put_idx);
11126                let (table_id, _) = &staging[put_idx];
11127                let Some(table_name) = table_names.get(table_id).cloned() else {
11128                    continue;
11129                };
11130                let old = old_rows.get(&delete_idx).cloned();
11131                let new = match &staging[put_idx].1 {
11132                    Staged::Put(cells) => Some(TriggerRowImage::from_cells(cells)),
11133                    _ => None,
11134                };
11135                let changed_columns = changed_columns(old.as_ref(), new.as_ref());
11136                events.push(WriteEvent {
11137                    table: table_name,
11138                    kind: TriggerEvent::Update,
11139                    old,
11140                    new,
11141                    changed_columns,
11142                    op_indices: vec![delete_idx, put_idx],
11143                    put_idx: Some(put_idx),
11144                    trigger_stack: Self::trigger_stack_for_indices(
11145                        trigger_stacks,
11146                        &[delete_idx, put_idx],
11147                    ),
11148                });
11149            }
11150        }
11151
11152        for (idx, (table_id, staged)) in staging.iter().enumerate() {
11153            commit_prepare_checkpoint(control, idx)?;
11154            let Some(table_name) = table_names.get(table_id).cloned() else {
11155                continue;
11156            };
11157            match staged {
11158                Staged::Put(cells) if !paired_put.contains(&idx) => {
11159                    let new = Some(TriggerRowImage::from_cells(cells));
11160                    let changed_columns = cells.iter().map(|(id, _)| *id).collect();
11161                    events.push(WriteEvent {
11162                        table: table_name,
11163                        kind: TriggerEvent::Insert,
11164                        old: None,
11165                        new,
11166                        changed_columns,
11167                        op_indices: vec![idx],
11168                        put_idx: Some(idx),
11169                        trigger_stack: Self::trigger_stack_for_indices(trigger_stacks, &[idx]),
11170                    });
11171                }
11172                Staged::Delete(row_id) if !paired_delete.contains(&idx) => {
11173                    let old = match old_rows.get(&idx).cloned() {
11174                        Some(old) => Some(old),
11175                        None => {
11176                            let handle = self.table_by_id(*table_id)?;
11177                            let row = handle.lock().get(*row_id, snapshot);
11178                            row.map(TriggerRowImage::from_row)
11179                        }
11180                    };
11181                    let Some(old) = old else {
11182                        continue;
11183                    };
11184                    let changed_columns = old.columns.keys().copied().collect();
11185                    events.push(WriteEvent {
11186                        table: table_name,
11187                        kind: TriggerEvent::Delete,
11188                        old: Some(old),
11189                        new: None,
11190                        changed_columns,
11191                        op_indices: vec![idx],
11192                        put_idx: None,
11193                        trigger_stack: Self::trigger_stack_for_indices(trigger_stacks, &[idx]),
11194                    });
11195                }
11196                Staged::Update { new_row: cells, .. } => {
11197                    let old = old_rows.get(&idx).cloned();
11198                    let new = Some(TriggerRowImage::from_cells(cells));
11199                    let changed_columns = changed_columns(old.as_ref(), new.as_ref());
11200                    events.push(WriteEvent {
11201                        table: table_name,
11202                        kind: TriggerEvent::Update,
11203                        old,
11204                        new,
11205                        changed_columns,
11206                        op_indices: vec![idx],
11207                        put_idx: Some(idx),
11208                        trigger_stack: Self::trigger_stack_for_indices(trigger_stacks, &[idx]),
11209                    });
11210                }
11211                Staged::Truncate => {}
11212                _ => {}
11213            }
11214        }
11215
11216        Ok(events)
11217    }
11218
11219    #[allow(clippy::too_many_arguments)]
11220    fn execute_trigger_program(
11221        &self,
11222        trigger: &StoredTrigger,
11223        event: &WriteEvent,
11224        staging: Option<&mut Vec<(u64, crate::txn::Staged)>>,
11225        out: &mut TriggerProgramOutput<'_>,
11226        trigger_stack: &[String],
11227        config: &TriggerConfig,
11228        read_epoch: Epoch,
11229        control: Option<&crate::ExecutionControl>,
11230    ) -> Result<TriggerProgramOutcome> {
11231        let mut event = event.clone();
11232        let mut select_results: HashMap<String, Vec<TriggerRowImage>> = HashMap::new();
11233        self.execute_trigger_steps(
11234            trigger,
11235            &trigger.program.steps,
11236            &mut event,
11237            staging,
11238            out,
11239            trigger_stack,
11240            config,
11241            &mut select_results,
11242            0,
11243            None,
11244            read_epoch,
11245            control,
11246        )
11247    }
11248
11249    #[allow(clippy::too_many_arguments)]
11250    fn execute_trigger_steps(
11251        &self,
11252        trigger: &StoredTrigger,
11253        steps: &[TriggerStep],
11254        event: &mut WriteEvent,
11255        mut staging: Option<&mut Vec<(u64, crate::txn::Staged)>>,
11256        out: &mut TriggerProgramOutput<'_>,
11257        trigger_stack: &[String],
11258        config: &TriggerConfig,
11259        select_results: &mut HashMap<String, Vec<TriggerRowImage>>,
11260        depth: u32,
11261        selected: Option<&TriggerRowImage>,
11262        read_epoch: Epoch,
11263        control: Option<&crate::ExecutionControl>,
11264    ) -> Result<TriggerProgramOutcome> {
11265        let _ = depth;
11266        for (step_index, step) in steps.iter().enumerate() {
11267            commit_prepare_checkpoint(control, step_index)?;
11268            match step {
11269                TriggerStep::SetNew { cells } => {
11270                    if trigger.timing != TriggerTiming::Before {
11271                        return Err(MongrelError::InvalidArgument(
11272                            "SetNew trigger step is only valid in BEFORE triggers".into(),
11273                        ));
11274                    }
11275                    let put_idx = event.put_idx.ok_or_else(|| {
11276                        MongrelError::InvalidArgument(
11277                            "SetNew trigger step requires INSERT or UPDATE NEW row".into(),
11278                        )
11279                    })?;
11280                    let staging = staging.as_deref_mut().ok_or_else(|| {
11281                        MongrelError::InvalidArgument(
11282                            "SetNew trigger step requires mutable trigger staging".into(),
11283                        )
11284                    })?;
11285                    let mut update_changed_columns = None;
11286                    let row_cells = match staging.get_mut(put_idx).map(|(_, op)| op) {
11287                        Some(crate::txn::Staged::Put(cells)) => cells,
11288                        Some(crate::txn::Staged::Update {
11289                            new_row,
11290                            changed_columns,
11291                            ..
11292                        }) => {
11293                            update_changed_columns = Some(changed_columns);
11294                            new_row
11295                        }
11296                        _ => {
11297                            return Err(MongrelError::InvalidArgument(
11298                                "SetNew trigger step target row is not mutable".into(),
11299                            ))
11300                        }
11301                    };
11302                    for (column_id, value) in eval_trigger_cells(cells, event, selected)? {
11303                        row_cells.retain(|(id, _)| *id != column_id);
11304                        row_cells.push((column_id, value.clone()));
11305                        if let Some(changed_columns) = &mut update_changed_columns {
11306                            changed_columns.push(column_id);
11307                        }
11308                        if let Some(new) = &mut event.new {
11309                            new.columns.insert(column_id, value);
11310                        }
11311                    }
11312                    row_cells.sort_by_key(|(id, _)| *id);
11313                    if let Some(changed_columns) = update_changed_columns {
11314                        changed_columns.sort_unstable();
11315                        changed_columns.dedup();
11316                    }
11317                }
11318                TriggerStep::Insert { table, cells } => {
11319                    let cells = eval_trigger_cells(cells, event, selected)?;
11320                    if let Ok(table_id) = self.table_id(table) {
11321                        out.added.push((table_id, crate::txn::Staged::Put(cells)));
11322                        out.added_stacks.push(trigger_stack.to_vec());
11323                    } else if self.external_table(table).is_some() {
11324                        out.added_external.push(ExternalTriggerWrite::Insert {
11325                            table: table.clone(),
11326                            cells,
11327                        });
11328                    } else {
11329                        return Err(MongrelError::NotFound(format!(
11330                            "trigger {:?} insert target {table:?} not found",
11331                            trigger.name
11332                        )));
11333                    }
11334                }
11335                TriggerStep::UpdateByPk { table, pk, cells } => {
11336                    let pk = eval_trigger_value(pk, event, selected)?;
11337                    let cells = eval_trigger_cells(cells, event, selected)?;
11338                    if self.external_table(table).is_some() {
11339                        out.added_external.push(ExternalTriggerWrite::UpdateByPk {
11340                            table: table.clone(),
11341                            pk,
11342                            cells,
11343                        });
11344                    } else {
11345                        let row_id = self
11346                            .table(table)?
11347                            .lock()
11348                            .lookup_pk(&pk.encode_key())
11349                            .ok_or_else(|| {
11350                                MongrelError::NotFound(format!(
11351                                    "trigger {:?} update target not found",
11352                                    trigger.name
11353                                ))
11354                            })?;
11355                        let handle = self.table(table)?;
11356                        let snapshot = self.snapshot_for_epoch(self.epoch.visible());
11357                        let old = handle.lock().get(row_id, snapshot).ok_or_else(|| {
11358                            MongrelError::NotFound(format!(
11359                                "trigger {:?} update target not visible",
11360                                trigger.name
11361                            ))
11362                        })?;
11363                        let mut changed_columns = cells
11364                            .iter()
11365                            .map(|(column_id, _)| *column_id)
11366                            .collect::<Vec<_>>();
11367                        changed_columns.sort_unstable();
11368                        changed_columns.dedup();
11369                        let mut merged = old.columns;
11370                        for (column_id, value) in cells {
11371                            merged.insert(column_id, value);
11372                        }
11373                        out.added.push((
11374                            self.table_id(table)?,
11375                            crate::txn::Staged::Update {
11376                                row_id,
11377                                new_row: merged.into_iter().collect(),
11378                                changed_columns,
11379                            },
11380                        ));
11381                        out.added_stacks.push(trigger_stack.to_vec());
11382                    }
11383                }
11384                TriggerStep::DeleteByPk { table, pk } => {
11385                    let pk = eval_trigger_value(pk, event, selected)?;
11386                    if self.external_table(table).is_some() {
11387                        out.added_external.push(ExternalTriggerWrite::DeleteByPk {
11388                            table: table.clone(),
11389                            pk,
11390                        });
11391                    } else {
11392                        let row_id = self
11393                            .table(table)?
11394                            .lock()
11395                            .lookup_pk(&pk.encode_key())
11396                            .ok_or_else(|| {
11397                                MongrelError::NotFound(format!(
11398                                    "trigger {:?} delete target not found",
11399                                    trigger.name
11400                                ))
11401                            })?;
11402                        out.added
11403                            .push((self.table_id(table)?, crate::txn::Staged::Delete(row_id)));
11404                        out.added_stacks.push(trigger_stack.to_vec());
11405                    }
11406                }
11407                TriggerStep::Select {
11408                    id,
11409                    table,
11410                    conditions,
11411                } => {
11412                    let schema = self.table(table)?.lock().schema().clone();
11413                    let snapshot = self.snapshot_for_epoch(read_epoch);
11414                    let handle = self.table(table)?;
11415                    let rows = match control {
11416                        Some(control) => {
11417                            handle.lock().visible_rows_controlled(snapshot, control)?
11418                        }
11419                        None => handle.lock().visible_rows(snapshot)?,
11420                    };
11421                    let mut matched = Vec::new();
11422                    for (row_index, row) in rows.into_iter().enumerate() {
11423                        commit_prepare_checkpoint(control, row_index)?;
11424                        let image = TriggerRowImage::from_row(row);
11425                        let passes = conditions
11426                            .iter()
11427                            .map(|cond| eval_trigger_condition(cond, event, &image, &schema))
11428                            .collect::<Result<Vec<_>>>()?
11429                            .into_iter()
11430                            .all(|b| b);
11431                        if passes {
11432                            matched.push(image);
11433                        }
11434                    }
11435                    if let Some(pk) = schema.primary_key() {
11436                        matched.sort_by(|a, b| {
11437                            let av = a.columns.get(&pk.id).unwrap_or(&Value::Null);
11438                            let bv = b.columns.get(&pk.id).unwrap_or(&Value::Null);
11439                            value_order(av, bv).unwrap_or(std::cmp::Ordering::Equal)
11440                        });
11441                    }
11442                    select_results.insert(id.clone(), matched);
11443                }
11444                TriggerStep::Foreach { id, steps } => {
11445                    let rows = select_results.get(id).ok_or_else(|| {
11446                        MongrelError::InvalidArgument(format!(
11447                            "trigger {:?} foreach references unknown select id {id:?}",
11448                            trigger.name
11449                        ))
11450                    })?;
11451                    if rows.len() > config.max_loop_iterations as usize {
11452                        return Err(MongrelError::InvalidArgument(format!(
11453                            "trigger {:?} foreach exceeded max_loop_iterations ({})",
11454                            trigger.name, config.max_loop_iterations
11455                        )));
11456                    }
11457                    for (row_index, row) in rows.clone().into_iter().enumerate() {
11458                        commit_prepare_checkpoint(control, row_index)?;
11459                        let result = self.execute_trigger_steps(
11460                            trigger,
11461                            steps,
11462                            event,
11463                            staging.as_deref_mut(),
11464                            out,
11465                            trigger_stack,
11466                            config,
11467                            select_results,
11468                            depth + 1,
11469                            Some(&row),
11470                            read_epoch,
11471                            control,
11472                        )?;
11473                        if result == TriggerProgramOutcome::Ignore {
11474                            return Ok(TriggerProgramOutcome::Ignore);
11475                        }
11476                    }
11477                }
11478                TriggerStep::DeleteWhere { table, conditions } => {
11479                    let schema = self.table(table)?.lock().schema().clone();
11480                    let snapshot = self.snapshot_for_epoch(read_epoch);
11481                    let handle = self.table(table)?;
11482                    let rows = match control {
11483                        Some(control) => {
11484                            handle.lock().visible_rows_controlled(snapshot, control)?
11485                        }
11486                        None => handle.lock().visible_rows(snapshot)?,
11487                    };
11488                    let table_id = self.table_id(table)?;
11489                    let mut to_delete = Vec::new();
11490                    for (row_index, row) in rows.into_iter().enumerate() {
11491                        commit_prepare_checkpoint(control, row_index)?;
11492                        let image = TriggerRowImage::from_row(row.clone());
11493                        let passes = conditions
11494                            .iter()
11495                            .map(|cond| eval_trigger_condition(cond, event, &image, &schema))
11496                            .collect::<Result<Vec<_>>>()?
11497                            .into_iter()
11498                            .all(|b| b);
11499                        if passes {
11500                            to_delete.push((table_id, row.row_id));
11501                        }
11502                    }
11503                    for (row_index, (table_id, row_id)) in to_delete.into_iter().enumerate() {
11504                        commit_prepare_checkpoint(control, row_index)?;
11505                        out.added
11506                            .push((table_id, crate::txn::Staged::Delete(row_id)));
11507                        out.added_stacks.push(trigger_stack.to_vec());
11508                    }
11509                }
11510                TriggerStep::UpdateWhere {
11511                    table,
11512                    conditions,
11513                    cells,
11514                } => {
11515                    let schema = self.table(table)?.lock().schema().clone();
11516                    let snapshot = self.snapshot_for_epoch(read_epoch);
11517                    let handle = self.table(table)?;
11518                    let rows = match control {
11519                        Some(control) => {
11520                            handle.lock().visible_rows_controlled(snapshot, control)?
11521                        }
11522                        None => handle.lock().visible_rows(snapshot)?,
11523                    };
11524                    let table_id = self.table_id(table)?;
11525                    let mut changed_columns =
11526                        cells.iter().map(|cell| cell.column_id).collect::<Vec<_>>();
11527                    changed_columns.sort_unstable();
11528                    changed_columns.dedup();
11529                    let mut to_update = Vec::new();
11530                    for (row_index, row) in rows.into_iter().enumerate() {
11531                        commit_prepare_checkpoint(control, row_index)?;
11532                        let image = TriggerRowImage::from_row(row.clone());
11533                        let passes = conditions
11534                            .iter()
11535                            .map(|cond| eval_trigger_condition(cond, event, &image, &schema))
11536                            .collect::<Result<Vec<_>>>()?
11537                            .into_iter()
11538                            .all(|b| b);
11539                        if passes {
11540                            let new_cells = cells
11541                                .iter()
11542                                .map(|cell| {
11543                                    Ok((
11544                                        cell.column_id,
11545                                        eval_trigger_value(&cell.value, event, Some(&image))?,
11546                                    ))
11547                                })
11548                                .collect::<Result<Vec<_>>>()?;
11549                            let mut merged = row.columns.clone();
11550                            for (column_id, value) in new_cells {
11551                                merged.insert(column_id, value);
11552                            }
11553                            to_update.push((table_id, row.row_id, merged));
11554                        }
11555                    }
11556                    for (row_index, (table_id, row_id, merged)) in to_update.into_iter().enumerate()
11557                    {
11558                        commit_prepare_checkpoint(control, row_index)?;
11559                        out.added.push((
11560                            table_id,
11561                            crate::txn::Staged::Update {
11562                                row_id,
11563                                new_row: merged.into_iter().collect(),
11564                                changed_columns: changed_columns.clone(),
11565                            },
11566                        ));
11567                        out.added_stacks.push(trigger_stack.to_vec());
11568                    }
11569                }
11570                TriggerStep::Raise { action, message } => match action {
11571                    TriggerRaiseAction::Ignore => return Ok(TriggerProgramOutcome::Ignore),
11572                    TriggerRaiseAction::Abort
11573                    | TriggerRaiseAction::Fail
11574                    | TriggerRaiseAction::Rollback => {
11575                        let message = eval_trigger_value(message, event, selected)?;
11576                        return Err(MongrelError::TriggerValidation(format!(
11577                            "trigger {:?} raised: {}; trigger stack: {}",
11578                            trigger.name,
11579                            trigger_message(message),
11580                            Self::format_trigger_stack(trigger_stack)
11581                        )));
11582                    }
11583                },
11584            }
11585        }
11586        Ok(TriggerProgramOutcome::Continue)
11587    }
11588
11589    fn trigger_stack_for_indices(stacks: Option<&[Vec<String>]>, indices: &[usize]) -> Vec<String> {
11590        let Some(stacks) = stacks else {
11591            return Vec::new();
11592        };
11593        let mut out = Vec::new();
11594        for idx in indices {
11595            let Some(stack) = stacks.get(*idx) else {
11596                continue;
11597            };
11598            for name in stack {
11599                if !out.iter().any(|existing| existing == name) {
11600                    out.push(name.clone());
11601                }
11602            }
11603        }
11604        out
11605    }
11606
11607    fn trigger_stack_with(stack: &[String], trigger_name: &str) -> Vec<String> {
11608        let mut out = stack.to_vec();
11609        out.push(trigger_name.to_string());
11610        out
11611    }
11612
11613    fn format_trigger_stack(stack: &[String]) -> String {
11614        if stack.is_empty() {
11615            "<root>".into()
11616        } else {
11617            stack.join(" -> ")
11618        }
11619    }
11620
11621    /// Authoritatively validate every declared constraint on the staged write
11622    /// set under the transaction's read snapshot, AND expand ON DELETE CASCADE /
11623    /// SET NULL actions into explicit child ops. Called from
11624    /// [`Self::commit_transaction`] outside the WAL mutex. Returns the first
11625    /// violation as an `Err`, aborting the commit atomically. This is the
11626    /// server-side authority point: concurrent remote writers that each pass
11627    /// their own client-side checks still cannot both commit a violating batch.
11628    ///
11629    /// Scope: CHECK (full, three-valued), UNIQUE beyond the PK (existence scan +
11630    /// intra-transaction dedup; concurrent-txn races are additionally caught by
11631    /// `WriteKey::Unique`), and FK insert-side parent existence + ON DELETE
11632    /// {RESTRICT, CASCADE, SET NULL}. CASCADE appends child deletes (transitive
11633    /// fixpoint); SET NULL appends child updates (FK columns nulled). Truncate is
11634    /// RESTRICT-only (cascade-truncate is unsupported).
11635    /// S1B-003: acquire Exclusive key claims for every primary key and declared
11636    /// UNIQUE key a transaction's staged puts/updates insert, in ascending key
11637    /// order (ordered acquisition cannot cycle on its own). A concurrent
11638    /// transaction claiming the same key blocks until this one ends, turning
11639    /// the optimistic write-write conflict into a serialization point. Claims
11640    /// release with the transaction's [`TxnLockGuard`].
11641    fn acquire_unique_key_claims(
11642        &self,
11643        txn_id: u64,
11644        staging: &[(u64, crate::txn::Staged)],
11645        control: Option<&crate::ExecutionControl>,
11646    ) -> Result<()> {
11647        let catalog = self.catalog.read();
11648        let has_uniques = staging.iter().any(|(table_id, staged)| {
11649            matches!(
11650                staged,
11651                crate::txn::Staged::Put(_) | crate::txn::Staged::Update { .. }
11652            ) && catalog.tables.iter().any(|entry| {
11653                entry.table_id == *table_id
11654                    && (entry.schema.primary_key().is_some()
11655                        || !entry.schema.constraints.uniques.is_empty())
11656            })
11657        });
11658        if !has_uniques {
11659            return Ok(());
11660        }
11661        let mut claims: Vec<(u64, Vec<u8>)> = Vec::new();
11662        for (staged_index, (table_id, staged)) in staging.iter().enumerate() {
11663            commit_prepare_checkpoint(control, staged_index)?;
11664            let cells = match staged {
11665                crate::txn::Staged::Put(cells) => cells,
11666                crate::txn::Staged::Update { new_row, .. } => new_row,
11667                _ => continue,
11668            };
11669            let Some(entry) = catalog
11670                .tables
11671                .iter()
11672                .find(|entry| entry.table_id == *table_id)
11673            else {
11674                continue;
11675            };
11676            for column in &entry.schema.columns {
11677                if !column
11678                    .flags
11679                    .contains(crate::schema::ColumnFlags::PRIMARY_KEY)
11680                {
11681                    continue;
11682                }
11683                if let Some((_, value)) = cells.iter().find(|(id, _)| *id == column.id) {
11684                    let mut key = b"pk:".to_vec();
11685                    key.extend_from_slice(&value.encode_key());
11686                    claims.push((*table_id, key));
11687                }
11688            }
11689            // Declared non-PK unique constraints claim their own namespace
11690            // (folding the constraint id into the key, per LockKey::Key's
11691            // multi-key-space rule). NULL components skip the constraint —
11692            // and the claim — per SQL semantics.
11693            let cells_map: HashMap<u16, Value> = cells.iter().cloned().collect();
11694            for uc in &entry.schema.constraints.uniques {
11695                if let Some(composite) =
11696                    crate::constraint::encode_composite_key(&uc.columns, &cells_map)
11697                {
11698                    let mut key = format!("uq{}:", uc.id).into_bytes();
11699                    key.extend_from_slice(&composite);
11700                    claims.push((*table_id, key));
11701                }
11702            }
11703        }
11704        claims.sort();
11705        claims.dedup();
11706        for (table_id, key) in claims {
11707            self.acquire_txn_lock(
11708                txn_id,
11709                crate::locks::LockKey::key(table_id, key),
11710                crate::locks::LockMode::Exclusive,
11711                control,
11712            )?;
11713        }
11714        Ok(())
11715    }
11716
11717    /// S1B-003: one FK parent-protection acquisition. `Err` propagates the
11718    /// deadlock/deadline/cancellation outcome; the test seam fires after each
11719    /// successful acquisition.
11720    fn acquire_fk_lock(
11721        &self,
11722        txn_id: u64,
11723        table_id: u64,
11724        key: &[u8],
11725        mode: crate::locks::LockMode,
11726        control: Option<&crate::ExecutionControl>,
11727    ) -> Result<()> {
11728        let mut namespaced = b"fk:".to_vec();
11729        namespaced.extend_from_slice(key);
11730        self.acquire_txn_lock(
11731            txn_id,
11732            crate::locks::LockKey::key(table_id, namespaced),
11733            mode,
11734            control,
11735        )?;
11736        // The hook is cloned out before firing: holding the slot's mutex while
11737        // a parked hook waits would block every other commit's hook call.
11738        let hook = self.fk_lock_hook.lock().clone();
11739        if let Some(hook) = hook {
11740            hook();
11741        }
11742        Ok(())
11743    }
11744
11745    fn validate_constraints(
11746        &self,
11747        txn_id: u64,
11748        staging: &mut Vec<(u64, crate::txn::Staged)>,
11749        read_epoch: Epoch,
11750        principal: Option<&crate::auth::Principal>,
11751        control: Option<&crate::ExecutionControl>,
11752    ) -> Result<()> {
11753        use crate::constraint::{encode_composite_key, validate_checks, FkAction};
11754        use crate::memtable::Row;
11755        use crate::txn::Staged;
11756        use std::collections::HashSet;
11757
11758        commit_prepare_checkpoint(control, 0)?;
11759        // P0.5: constraint checks must use an HLC-pinned snapshot so parent /
11760        // unique rows stamped with commit_ts remain visible.
11761        let snapshot = self.snapshot_for_epoch(read_epoch);
11762        let cat = self.catalog.read().clone();
11763
11764        // Collect live (id, name, constraints-bearing?) for staged tables.
11765        let live: Vec<(u64, String, crate::schema::Schema)> = cat
11766            .tables
11767            .iter()
11768            .filter(|entry| matches!(entry.state, TableState::Live | TableState::Building { .. }))
11769            .map(|e| (e.table_id, e.name.clone(), e.schema.clone()))
11770            .collect();
11771
11772        // Fast path: bail if no live table declares any constraints at all.
11773        let any_constraints = live.iter().any(|(_, _, s)| !s.constraints.is_empty());
11774        if !any_constraints {
11775            self.materialize_generated_embeddings(staging, control)?;
11776            return Ok(());
11777        }
11778
11779        // Lazily-loaded visible rows per table, shared across checks.
11780        let mut rows_cache: HashMap<u64, Vec<Row>> = HashMap::new();
11781        let mut load_rows = |table_id: u64| -> Result<Vec<Row>> {
11782            if let Some(r) = rows_cache.get(&table_id) {
11783                return Ok(r.clone());
11784            }
11785            let handle = self.table_by_id(table_id)?;
11786            let rows = match control {
11787                Some(control) => handle.lock().visible_rows_controlled(snapshot, control)?,
11788                None => handle.lock().visible_rows(snapshot)?,
11789            };
11790            rows_cache.insert(table_id, rows.clone());
11791            Ok(rows)
11792        };
11793
11794        // ── Phase A1: expand ON UPDATE CASCADE / SET NULL while updates still
11795        // carry an explicit old RowId + full new image. This makes action choice
11796        // reliable even when the referenced key itself changes; a delete+put
11797        // heuristic cannot distinguish that from unrelated operations.
11798        let mut processed_updates = HashSet::new();
11799        type PendingUpdate = (usize, u64, crate::rowid::RowId, Vec<(u16, Value)>);
11800        let mut update_pass = 0_usize;
11801        loop {
11802            commit_prepare_checkpoint(control, update_pass)?;
11803            update_pass += 1;
11804            let updates: Vec<PendingUpdate> = staging
11805                .iter()
11806                .enumerate()
11807                .filter_map(|(index, (table_id, op))| match op {
11808                    Staged::Update {
11809                        row_id,
11810                        new_row: cells,
11811                        ..
11812                    } if !processed_updates.contains(&index) => {
11813                        Some((index, *table_id, *row_id, cells.clone()))
11814                    }
11815                    _ => None,
11816                })
11817                .collect();
11818            if updates.is_empty() {
11819                break;
11820            }
11821            let mut new_ops = Vec::new();
11822            for (update_index, (index, table_id, row_id, new_cells)) in
11823                updates.into_iter().enumerate()
11824            {
11825                commit_prepare_checkpoint(control, update_index)?;
11826                processed_updates.insert(index);
11827                let Some(tname) = live
11828                    .iter()
11829                    .find(|(id, _, _)| *id == table_id)
11830                    .map(|(_, name, _)| name.as_str())
11831                else {
11832                    continue;
11833                };
11834                let Some(old_row) = self.table_by_id(table_id)?.lock().get(row_id, snapshot) else {
11835                    continue;
11836                };
11837                let new_map: HashMap<u16, Value> = new_cells.iter().cloned().collect();
11838                for (child_id, _child_name, child_schema) in &live {
11839                    for fk in &child_schema.constraints.foreign_keys {
11840                        if fk.ref_table != tname {
11841                            continue;
11842                        }
11843                        let Some(old_key) = encode_composite_key(&fk.ref_columns, &old_row.columns)
11844                        else {
11845                            continue;
11846                        };
11847                        if encode_composite_key(&fk.ref_columns, &new_map).as_deref()
11848                            == Some(old_key.as_slice())
11849                        {
11850                            continue;
11851                        }
11852                        if fk.on_update == FkAction::Restrict {
11853                            continue;
11854                        }
11855                        // S1B-003: the referenced key is being changed, so this
11856                        // update removes it for any action — hold an Exclusive
11857                        // parent-protection lock against concurrent child
11858                        // inserts referencing the old key.
11859                        self.acquire_fk_lock(
11860                            txn_id,
11861                            table_id,
11862                            &old_key,
11863                            crate::locks::LockMode::Exclusive,
11864                            control,
11865                        )?;
11866                        let child_rows = load_rows(*child_id)?;
11867                        for (child_index, child) in child_rows.into_iter().enumerate() {
11868                            commit_prepare_checkpoint(control, child_index)?;
11869                            if encode_composite_key(&fk.columns, &child.columns).as_deref()
11870                                != Some(old_key.as_slice())
11871                            {
11872                                continue;
11873                            }
11874                            if staging.iter().any(|(id, op)| {
11875                                *id == *child_id
11876                                    && matches!(op, Staged::Delete(id) if *id == child.row_id)
11877                            }) {
11878                                continue;
11879                            }
11880                            let mut cells: Vec<(u16, Value)> = child
11881                                .columns
11882                                .iter()
11883                                .map(|(column_id, value)| (*column_id, value.clone()))
11884                                .collect();
11885                            for (child_column, parent_column) in
11886                                fk.columns.iter().zip(&fk.ref_columns)
11887                            {
11888                                cells.retain(|(column_id, _)| column_id != child_column);
11889                                let value = match fk.on_update {
11890                                    FkAction::Cascade => {
11891                                        new_map.get(parent_column).cloned().unwrap_or(Value::Null)
11892                                    }
11893                                    FkAction::SetNull => Value::Null,
11894                                    FkAction::Restrict => {
11895                                        return Err(MongrelError::Other(
11896                                            "restricted foreign-key update reached cascade preparation"
11897                                                .into(),
11898                                        ));
11899                                    }
11900                                };
11901                                cells.push((*child_column, value));
11902                            }
11903                            cells.sort_by_key(|(column_id, _)| *column_id);
11904                            if let Some(existing_index) = staging.iter().position(|(id, op)| {
11905                                *id == *child_id
11906                                    && matches!(op, Staged::Update { row_id, .. } if *row_id == child.row_id)
11907                            }) {
11908                                if let Staged::Update {
11909                                    new_row: existing,
11910                                    changed_columns,
11911                                    ..
11912                                } = &mut staging[existing_index].1 {
11913                                    changed_columns.extend(fk.columns.iter().copied());
11914                                    changed_columns.sort_unstable();
11915                                    changed_columns.dedup();
11916                                    if *existing != cells {
11917                                        *existing = cells;
11918                                        processed_updates.remove(&existing_index);
11919                                    }
11920                                }
11921                            } else {
11922                                new_ops.push((
11923                                    *child_id,
11924                                    Staged::Update {
11925                                        row_id: child.row_id,
11926                                        new_row: cells,
11927                                        changed_columns: fk.columns.clone(),
11928                                    },
11929                                ));
11930                            }
11931                        }
11932                    }
11933                }
11934            }
11935            staging.extend(new_ops);
11936        }
11937
11938        // ── Phase A2: expand ON DELETE CASCADE / SET NULL into explicit child
11939        // ops (transitive fixpoint). RESTRICT is not expanded here — it is
11940        // enforced as a violation in Phase B. `cascaded` records every delete
11941        // we have already expanded so a self-referential CASCADE FK cannot loop.
11942        let mut cascaded: HashSet<(u64, u64)> = HashSet::new();
11943        let mut cascade_pass = 0_usize;
11944        loop {
11945            commit_prepare_checkpoint(control, cascade_pass)?;
11946            cascade_pass += 1;
11947            let mut new_ops: Vec<(u64, Staged)> = Vec::new();
11948            let deletes: Vec<(u64, crate::rowid::RowId)> = staging
11949                .iter()
11950                .filter_map(|(t, op)| match op {
11951                    Staged::Delete(rid) => Some((*t, *rid)),
11952                    _ => None,
11953                })
11954                .collect();
11955            for (delete_index, (table_id, rid)) in deletes.into_iter().enumerate() {
11956                commit_prepare_checkpoint(control, delete_index)?;
11957                if !cascaded.insert((table_id, rid.0)) {
11958                    continue;
11959                }
11960                let Some(tname) = live
11961                    .iter()
11962                    .find(|(t, _, _)| *t == table_id)
11963                    .map(|(_, n, _)| n.as_str())
11964                else {
11965                    continue;
11966                };
11967                let parent_handle = self.table_by_id(table_id)?;
11968                let Some(parent_row) = parent_handle.lock().get(rid, snapshot) else {
11969                    continue;
11970                };
11971                for (child_id, _child_name, child_schema) in &live {
11972                    for fk in &child_schema.constraints.foreign_keys {
11973                        if fk.ref_table != tname {
11974                            continue;
11975                        }
11976                        let Some(parent_key) =
11977                            encode_composite_key(&fk.ref_columns, &parent_row.columns)
11978                        else {
11979                            continue;
11980                        };
11981                        // Suppress ON DELETE cascade/set-null when this "delete"
11982                        // is actually half of an UPDATE encoded as Delete(old)+
11983                        // Put(new): if a staged Put in the SAME table still
11984                        // provides the referenced parent key, the parent still
11985                        // exists (its non-key columns changed) and the children
11986                        // must be left alone. A genuine delete, or an update
11987                        // that CHANGES the referenced key, has no preserving Put
11988                        // → cascade fires as before.
11989                        let key_preserved = staging.iter().any(|(t, op)| {
11990                            if *t != table_id {
11991                                return false;
11992                            }
11993                            let Staged::Put(cells) = op else {
11994                                return false;
11995                            };
11996                            let map: HashMap<u16, crate::memtable::Value> =
11997                                cells.iter().cloned().collect();
11998                            encode_composite_key(&fk.ref_columns, &map).as_deref()
11999                                == Some(parent_key.as_slice())
12000                        });
12001                        if key_preserved {
12002                            continue;
12003                        }
12004                        // S1B-003: the referenced parent key is genuinely being
12005                        // removed (delete, or the delete half of a key-changing
12006                        // update), for every FK action — hold an Exclusive
12007                        // parent-protection lock against concurrent child
12008                        // inserts referencing it. RESTRICT fks are enforced in
12009                        // Phase B; the claim covers them too.
12010                        self.acquire_fk_lock(
12011                            txn_id,
12012                            table_id,
12013                            &parent_key,
12014                            crate::locks::LockMode::Exclusive,
12015                            control,
12016                        )?;
12017                        match fk.on_delete {
12018                            FkAction::Restrict => continue,
12019                            FkAction::Cascade => {
12020                                let child_rows = load_rows(*child_id)?;
12021                                for (child_index, cr) in child_rows.iter().enumerate() {
12022                                    commit_prepare_checkpoint(control, child_index)?;
12023                                    if !cascaded.contains(&(*child_id, cr.row_id.0))
12024                                        && encode_composite_key(&fk.columns, &cr.columns).as_deref()
12025                                            == Some(parent_key.as_slice())
12026                                    {
12027                                        new_ops.push((*child_id, Staged::Delete(cr.row_id)));
12028                                    }
12029                                }
12030                            }
12031                            FkAction::SetNull => {
12032                                let child_rows = load_rows(*child_id)?;
12033                                for (child_index, cr) in child_rows.iter().enumerate() {
12034                                    commit_prepare_checkpoint(control, child_index)?;
12035                                    if !cascaded.contains(&(*child_id, cr.row_id.0))
12036                                        && encode_composite_key(&fk.columns, &cr.columns).as_deref()
12037                                            == Some(parent_key.as_slice())
12038                                    {
12039                                        // Re-emit the child row with the FK
12040                                        // columns set to NULL (delete + put).
12041                                        let mut cells: Vec<(u16, crate::memtable::Value)> = cr
12042                                            .columns
12043                                            .iter()
12044                                            .map(|(k, v)| (*k, v.clone()))
12045                                            .collect();
12046                                        for cid in &fk.columns {
12047                                            cells.retain(|(k, _)| k != cid);
12048                                            cells.push((*cid, crate::memtable::Value::Null));
12049                                        }
12050                                        new_ops.push((
12051                                            *child_id,
12052                                            Staged::Update {
12053                                                row_id: cr.row_id,
12054                                                new_row: cells,
12055                                                changed_columns: fk.columns.clone(),
12056                                            },
12057                                        ));
12058                                    }
12059                                }
12060                            }
12061                        }
12062                    }
12063                }
12064            }
12065            if new_ops.is_empty() {
12066                break;
12067            }
12068            staging.extend(new_ops);
12069        }
12070
12071        // Constraint actions can add writes. Re-authorize the final write set
12072        // before generated-value providers receive any row content, then
12073        // materialize vectors before Phase B validates the committed cells.
12074        self.validate_write_permissions(staging, principal, control)?;
12075        self.validate_security_writes(staging, read_epoch, principal, control)?;
12076        self.materialize_generated_embeddings(staging, control)?;
12077
12078        // Rows staged for deletion in THIS transaction (now including cascaded
12079        // deletes). Used to exclude the old version of an updated row from
12080        // unique-existence scans.
12081        let staged_deletes: HashSet<(u64, u64)> = staging
12082            .iter()
12083            .filter_map(|(t, op)| match op {
12084                Staged::Delete(rid) | Staged::Update { row_id: rid, .. } => Some((*t, rid.0)),
12085                _ => None,
12086            })
12087            .collect();
12088
12089        // Intra-transaction unique-key dedup: (table_id, uc_id, key).
12090        let mut seen_unique: HashSet<(u64, u16, Vec<u8>)> = HashSet::new();
12091
12092        // ── Phase B: validate the fully-expanded staging set.
12093        for (operation_index, (table_id, op)) in staging.iter().enumerate() {
12094            commit_prepare_checkpoint(control, operation_index)?;
12095            let Some((_, tname, schema)) = live.iter().find(|(t, _, _)| t == table_id) else {
12096                continue;
12097            };
12098            let cells_map: HashMap<u16, crate::memtable::Value>;
12099            match op {
12100                Staged::Put(cells) | Staged::Update { new_row: cells, .. } => {
12101                    cells_map = cells.iter().cloned().collect();
12102
12103                    // CHECK constraints.
12104                    if !schema.constraints.checks.is_empty() {
12105                        validate_checks(&schema.constraints.checks, &cells_map)?;
12106                    }
12107
12108                    // UNIQUE (non-PK) constraints.
12109                    for uc in &schema.constraints.uniques {
12110                        let Some(key) = encode_composite_key(&uc.columns, &cells_map) else {
12111                            continue; // NULL in a constrained column → skip (SQL).
12112                        };
12113                        let marker = (*table_id, uc.id, key.clone());
12114                        if !seen_unique.insert(marker) {
12115                            return Err(MongrelError::Conflict(format!(
12116                                "UNIQUE constraint '{}' on table '{tname}' violated within batch",
12117                                uc.name
12118                            )));
12119                        }
12120                        let rows = load_rows(*table_id)?;
12121                        for (row_index, r) in rows.iter().enumerate() {
12122                            commit_prepare_checkpoint(control, row_index)?;
12123                            // Skip rows this same transaction is deleting (the
12124                            // old version of an updated/cascade-deleted row).
12125                            if staged_deletes.contains(&(*table_id, r.row_id.0)) {
12126                                continue;
12127                            }
12128                            if let Some(theirs) = encode_composite_key(&uc.columns, &r.columns) {
12129                                if theirs == key {
12130                                    return Err(MongrelError::Conflict(format!(
12131                                        "UNIQUE constraint '{}' on table '{tname}' violated",
12132                                        uc.name
12133                                    )));
12134                                }
12135                            }
12136                        }
12137                    }
12138
12139                    // FK insert-side: parent must exist.
12140                    for fk in &schema.constraints.foreign_keys {
12141                        let Some(child_key) = encode_composite_key(&fk.columns, &cells_map) else {
12142                            continue; // NULL FK component → not checked (SQL).
12143                        };
12144                        let Some(parent_id) = cat
12145                            .tables
12146                            .iter()
12147                            .find(|t| t.name == fk.ref_table)
12148                            .map(|t| t.table_id)
12149                        else {
12150                            return Err(MongrelError::InvalidArgument(format!(
12151                                "FOREIGN KEY '{}' references unknown table '{}'",
12152                                fk.name, fk.ref_table
12153                            )));
12154                        };
12155                        // S1B-003: hold a Shared parent-protection lock on the
12156                        // referenced key while checking existence, so a
12157                        // concurrent parent delete or key-changing update
12158                        // serializes against this insert.
12159                        self.acquire_fk_lock(
12160                            txn_id,
12161                            parent_id,
12162                            &child_key,
12163                            crate::locks::LockMode::Shared,
12164                            control,
12165                        )?;
12166                        let parent_rows = load_rows(parent_id)?;
12167                        let mut found = false;
12168                        for (row_index, r) in parent_rows.iter().enumerate() {
12169                            commit_prepare_checkpoint(control, row_index)?;
12170                            if staged_deletes.contains(&(parent_id, r.row_id.0)) {
12171                                continue;
12172                            }
12173                            if let Some(pkey) = encode_composite_key(&fk.ref_columns, &r.columns) {
12174                                if pkey == child_key {
12175                                    found = true;
12176                                    break;
12177                                }
12178                            }
12179                        }
12180                        // Final-write-set FK validation: a parent inserted in
12181                        // THIS transaction also satisfies the FK. This enables
12182                        // atomic parent+child batches and cyclical/mutual FK
12183                        // inserts within a single transaction — the child sees
12184                        // the staged parent put even though it is not committed
12185                        // yet.
12186                        if !found {
12187                            for (staged_index, (st_table, st_op)) in staging.iter().enumerate() {
12188                                commit_prepare_checkpoint(control, staged_index)?;
12189                                if *st_table != parent_id {
12190                                    continue;
12191                                }
12192                                if let Staged::Put(pcells)
12193                                | Staged::Update {
12194                                    new_row: pcells, ..
12195                                } = st_op
12196                                {
12197                                    let pmap: HashMap<u16, crate::memtable::Value> =
12198                                        pcells.iter().cloned().collect();
12199                                    if let Some(pkey) = encode_composite_key(&fk.ref_columns, &pmap)
12200                                    {
12201                                        if pkey == child_key {
12202                                            found = true;
12203                                            break;
12204                                        }
12205                                    }
12206                                }
12207                            }
12208                        }
12209                        if !found {
12210                            return Err(MongrelError::Conflict(format!(
12211                                "FOREIGN KEY '{}' on table '{tname}' has no matching parent in '{}'",
12212                                fk.name, fk.ref_table
12213                            )));
12214                        }
12215                    }
12216
12217                    // Parent-side ON UPDATE RESTRICT. CASCADE/SET NULL were
12218                    // expanded in Phase A; here the final child write set is
12219                    // known, so a child explicitly moved/deleted by this same
12220                    // transaction does not cause a false violation.
12221                    if let Staged::Update { row_id, .. } = op {
12222                        let parent_handle = self.table_by_id(*table_id)?;
12223                        let Some(old_parent) = parent_handle.lock().get(*row_id, snapshot) else {
12224                            continue;
12225                        };
12226                        for (child_id, child_name, child_schema) in &live {
12227                            for fk in &child_schema.constraints.foreign_keys {
12228                                if fk.ref_table != *tname || fk.on_update != FkAction::Restrict {
12229                                    continue;
12230                                }
12231                                let Some(old_key) =
12232                                    encode_composite_key(&fk.ref_columns, &old_parent.columns)
12233                                else {
12234                                    continue;
12235                                };
12236                                if encode_composite_key(&fk.ref_columns, &cells_map).as_deref()
12237                                    == Some(old_key.as_slice())
12238                                {
12239                                    continue;
12240                                }
12241                                // S1B-003: the referenced key is being changed —
12242                                // hold an Exclusive parent-protection lock
12243                                // against concurrent child inserts referencing
12244                                // the old key.
12245                                self.acquire_fk_lock(
12246                                    txn_id,
12247                                    *table_id,
12248                                    &old_key,
12249                                    crate::locks::LockMode::Exclusive,
12250                                    control,
12251                                )?;
12252                                for (child_index, child) in
12253                                    load_rows(*child_id)?.into_iter().enumerate()
12254                                {
12255                                    commit_prepare_checkpoint(control, child_index)?;
12256                                    if encode_composite_key(&fk.columns, &child.columns).as_deref()
12257                                        != Some(old_key.as_slice())
12258                                    {
12259                                        continue;
12260                                    }
12261                                    let replacement = staging.iter().find_map(|(id, op)| {
12262                                        if *id != *child_id {
12263                                            return None;
12264                                        }
12265                                        match op {
12266                                            Staged::Delete(id) if *id == child.row_id => Some(None),
12267                                            Staged::Update {
12268                                                row_id,
12269                                                new_row: cells,
12270                                                ..
12271                                            } if *row_id == child.row_id => {
12272                                                let map: HashMap<u16, Value> =
12273                                                    cells.iter().cloned().collect();
12274                                                Some(encode_composite_key(&fk.columns, &map))
12275                                            }
12276                                            _ => None,
12277                                        }
12278                                    });
12279                                    if replacement.is_some_and(|key| {
12280                                        key.as_deref() != Some(old_key.as_slice())
12281                                    }) {
12282                                        continue;
12283                                    }
12284                                    return Err(MongrelError::Conflict(format!(
12285                                        "FOREIGN KEY '{}' on table '{child_name}' restricts update (parent key referenced)",
12286                                        fk.name
12287                                    )));
12288                                }
12289                            }
12290                        }
12291                    }
12292                }
12293                Staged::Delete(rid) => {
12294                    // FK ON DELETE RESTRICT: a child row (whose FK action is
12295                    // RESTRICT) referencing this parent blocks the delete.
12296                    // CASCADE/SET NULL children were expanded in Phase A.
12297                    let parent_handle = self.table_by_id(*table_id)?;
12298                    let Some(parent_row) = parent_handle.lock().get(*rid, snapshot) else {
12299                        continue;
12300                    };
12301                    for (child_id, child_name, child_schema) in &live {
12302                        for fk in &child_schema.constraints.foreign_keys {
12303                            if fk.ref_table != *tname || fk.on_delete != FkAction::Restrict {
12304                                continue;
12305                            }
12306                            let Some(parent_key) =
12307                                encode_composite_key(&fk.ref_columns, &parent_row.columns)
12308                            else {
12309                                continue;
12310                            };
12311                            let child_rows = load_rows(*child_id)?;
12312                            for (row_index, r) in child_rows.iter().enumerate() {
12313                                commit_prepare_checkpoint(control, row_index)?;
12314                                // A child already being deleted by this txn
12315                                // (cascade/inline) is not a restrict violation.
12316                                if staged_deletes.contains(&(*child_id, r.row_id.0)) {
12317                                    continue;
12318                                }
12319                                if let Some(ck) = encode_composite_key(&fk.columns, &r.columns) {
12320                                    if ck == parent_key {
12321                                        return Err(MongrelError::Conflict(format!(
12322                                            "FOREIGN KEY '{}' on table '{child_name}' restricts delete (parent referenced)",
12323                                            fk.name
12324                                        )));
12325                                    }
12326                                }
12327                            }
12328                        }
12329                    }
12330                }
12331                Staged::Truncate => {
12332                    // Truncate is RESTRICT-only: reject if any child references
12333                    // this table (any FK action), since cascade-truncate is
12334                    // unsupported.
12335                    for (child_id, child_name, child_schema) in &live {
12336                        for fk in &child_schema.constraints.foreign_keys {
12337                            if fk.ref_table != *tname {
12338                                continue;
12339                            }
12340                            let child_rows = load_rows(*child_id)?;
12341                            if child_rows
12342                                .iter()
12343                                .any(|r| encode_composite_key(&fk.columns, &r.columns).is_some())
12344                            {
12345                                return Err(MongrelError::Conflict(format!(
12346                                    "FOREIGN KEY '{}' on table '{child_name}' restricts truncate of '{tname}'",
12347                                    fk.name
12348                                )));
12349                            }
12350                        }
12351                    }
12352                }
12353            }
12354        }
12355        Ok(())
12356    }
12357
12358    fn validate_write_permissions(
12359        &self,
12360        staging: &[(u64, crate::txn::Staged)],
12361        principal: Option<&crate::auth::Principal>,
12362        control: Option<&crate::ExecutionControl>,
12363    ) -> Result<()> {
12364        commit_prepare_checkpoint(control, 0)?;
12365        if principal.is_none() && !self.auth_state.require_auth() {
12366            return Ok(());
12367        }
12368        let principal = principal.ok_or(MongrelError::AuthRequired)?;
12369        let needs = summarize_write_permissions(staging);
12370        let catalog = self.catalog.read();
12371
12372        if needs.values().any(|need| need.truncate) {
12373            self.require_for(Some(principal), &crate::auth::Permission::Admin)?;
12374        }
12375        for (need_index, (table_id, need)) in needs.into_iter().enumerate() {
12376            commit_prepare_checkpoint(control, need_index)?;
12377            let entry = catalog
12378                .tables
12379                .iter()
12380                .find(|entry| {
12381                    entry.table_id == table_id
12382                        && matches!(entry.state, TableState::Live | TableState::Building { .. })
12383                })
12384                .ok_or_else(|| {
12385                    MongrelError::NotFound(format!(
12386                        "live table {table_id} not found during write validation"
12387                    ))
12388                })?;
12389            if matches!(entry.state, TableState::Building { .. }) {
12390                self.require_for(Some(principal), &crate::auth::Permission::Ddl)?;
12391                continue;
12392            }
12393            if need.insert {
12394                Self::require_columns_for_principal(
12395                    &entry.name,
12396                    &entry.schema,
12397                    crate::auth::ColumnOperation::Insert,
12398                    &need.insert_columns,
12399                    principal,
12400                )?;
12401            }
12402            if need.update {
12403                Self::require_columns_for_principal(
12404                    &entry.name,
12405                    &entry.schema,
12406                    crate::auth::ColumnOperation::Update,
12407                    &need.update_columns,
12408                    principal,
12409                )?;
12410            }
12411            if need.delete {
12412                self.require_for(
12413                    Some(principal),
12414                    &crate::auth::Permission::Delete {
12415                        table: entry.name.clone(),
12416                    },
12417                )?;
12418            }
12419        }
12420        Ok(())
12421    }
12422
12423    fn validate_security_writes(
12424        &self,
12425        staging: &[(u64, crate::txn::Staged)],
12426        read_epoch: Epoch,
12427        explicit_principal: Option<&crate::auth::Principal>,
12428        control: Option<&crate::ExecutionControl>,
12429    ) -> Result<()> {
12430        commit_prepare_checkpoint(control, 0)?;
12431        use crate::security::PolicyCommand;
12432        use crate::txn::Staged;
12433
12434        let catalog = self.catalog.read();
12435        if catalog.security.rls_tables.is_empty() {
12436            return Ok(());
12437        }
12438        let security = catalog.security.clone();
12439        let table_names = catalog
12440            .tables
12441            .iter()
12442            .filter(|entry| matches!(entry.state, TableState::Live))
12443            .map(|entry| (entry.table_id, entry.name.clone()))
12444            .collect::<HashMap<_, _>>();
12445        drop(catalog);
12446        if !staging.iter().any(|(table_id, _)| {
12447            table_names
12448                .get(table_id)
12449                .is_some_and(|table| security.rls_enabled(table))
12450        }) {
12451            return Ok(());
12452        }
12453        let principal = explicit_principal.ok_or(MongrelError::AuthRequired)?;
12454
12455        for (operation_index, (table_id, operation)) in staging.iter().enumerate() {
12456            commit_prepare_checkpoint(control, operation_index)?;
12457            let Some(table) = table_names.get(table_id) else {
12458                continue;
12459            };
12460            if !security.rls_enabled(table) || principal.is_admin {
12461                continue;
12462            }
12463            let denied = |command| MongrelError::PermissionDenied {
12464                required: match command {
12465                    PolicyCommand::Insert => crate::auth::Permission::Insert {
12466                        table: table.clone(),
12467                    },
12468                    PolicyCommand::Update => crate::auth::Permission::Update {
12469                        table: table.clone(),
12470                    },
12471                    PolicyCommand::Delete | PolicyCommand::All | PolicyCommand::Select => {
12472                        crate::auth::Permission::Delete {
12473                            table: table.clone(),
12474                        }
12475                    }
12476                },
12477                principal: principal.username.clone(),
12478            };
12479            match operation {
12480                Staged::Put(cells) => {
12481                    let mut row = crate::memtable::Row::new(RowId(0), Epoch(read_epoch.0));
12482                    row.columns.extend(cells.iter().cloned());
12483                    if !security.row_allowed(table, PolicyCommand::Insert, &row, principal, true) {
12484                        return Err(denied(PolicyCommand::Insert));
12485                    }
12486                }
12487                Staged::Update {
12488                    row_id,
12489                    new_row: cells,
12490                    ..
12491                } => {
12492                    let old = self
12493                        .table_by_id(*table_id)?
12494                        .lock()
12495                        .get(*row_id, self.snapshot_for_epoch(read_epoch))
12496                        .ok_or_else(|| {
12497                            MongrelError::NotFound(format!("row {} not found", row_id.0))
12498                        })?;
12499                    if !security.row_allowed(table, PolicyCommand::Update, &old, principal, false) {
12500                        return Err(denied(PolicyCommand::Update));
12501                    }
12502                    let mut new = crate::memtable::Row::new(*row_id, Epoch(read_epoch.0));
12503                    new.columns.extend(cells.iter().cloned());
12504                    if !security.row_allowed(table, PolicyCommand::Update, &new, principal, true) {
12505                        return Err(denied(PolicyCommand::Update));
12506                    }
12507                }
12508                Staged::Delete(row_id) => {
12509                    let old = self
12510                        .table_by_id(*table_id)?
12511                        .lock()
12512                        .get(*row_id, self.snapshot_for_epoch(read_epoch))
12513                        .ok_or_else(|| {
12514                            MongrelError::NotFound(format!("row {} not found", row_id.0))
12515                        })?;
12516                    if !security.row_allowed(table, PolicyCommand::Delete, &old, principal, false) {
12517                        return Err(denied(PolicyCommand::Delete));
12518                    }
12519                }
12520                Staged::Truncate => return Err(denied(PolicyCommand::Delete)),
12521            }
12522        }
12523        Ok(())
12524    }
12525
12526    /// Seal a transaction (spec §9.3):
12527    /// 1. Prepare — derive write keys, allocate row ids (brief table locks).
12528    /// 2. Sequencer — validate-first under the WAL mutex; abort on conflict
12529    ///    with no epoch consumed; assign epoch, append data records + TxnCommit,
12530    ///    group-sync, record conflict keys.
12531    /// 3. Publish — apply to tables, advance visible in-order.
12532    #[allow(clippy::too_many_arguments)]
12533    pub(crate) fn commit_transaction_with_external_states(
12534        &self,
12535        txn_id: u64,
12536        read_epoch: Epoch,
12537        staging: Vec<(u64, crate::txn::Staged)>,
12538        external_states: Vec<(String, Vec<u8>)>,
12539        materialized_view_updates: Vec<crate::catalog::MaterializedViewEntry>,
12540        security_principal: Option<crate::auth::Principal>,
12541        principal_catalog_bound: bool,
12542        external_trigger_bridge: Option<&dyn ExternalTriggerBridge>,
12543        context: crate::txn::TxnCommitContext,
12544    ) -> Result<(Epoch, Vec<RowId>)> {
12545        self.commit_transaction_with_external_states_inner(
12546            txn_id,
12547            read_epoch,
12548            staging,
12549            external_states,
12550            materialized_view_updates,
12551            security_principal,
12552            principal_catalog_bound,
12553            external_trigger_bridge,
12554            context,
12555            None,
12556            None,
12557        )
12558    }
12559
12560    #[allow(clippy::too_many_arguments)]
12561    pub(crate) fn commit_transaction_with_external_states_controlled(
12562        &self,
12563        txn_id: u64,
12564        read_epoch: Epoch,
12565        staging: Vec<(u64, crate::txn::Staged)>,
12566        external_states: Vec<(String, Vec<u8>)>,
12567        materialized_view_updates: Vec<crate::catalog::MaterializedViewEntry>,
12568        security_principal: Option<crate::auth::Principal>,
12569        principal_catalog_bound: bool,
12570        external_trigger_bridge: Option<&dyn ExternalTriggerBridge>,
12571        context: crate::txn::TxnCommitContext,
12572        control: &crate::ExecutionControl,
12573        before_commit: &mut dyn FnMut() -> Result<()>,
12574    ) -> Result<(Epoch, Vec<RowId>)> {
12575        self.commit_transaction_with_external_states_inner(
12576            txn_id,
12577            read_epoch,
12578            staging,
12579            external_states,
12580            materialized_view_updates,
12581            security_principal,
12582            principal_catalog_bound,
12583            external_trigger_bridge,
12584            context,
12585            Some(control),
12586            Some(before_commit),
12587        )
12588    }
12589
12590    #[allow(clippy::too_many_arguments)]
12591    fn commit_transaction_with_external_states_inner(
12592        &self,
12593        txn_id: u64,
12594        read_epoch: Epoch,
12595        mut staging: Vec<(u64, crate::txn::Staged)>,
12596        external_states: Vec<(String, Vec<u8>)>,
12597        materialized_view_updates: Vec<crate::catalog::MaterializedViewEntry>,
12598        mut security_principal: Option<crate::auth::Principal>,
12599        principal_catalog_bound: bool,
12600        external_trigger_bridge: Option<&dyn ExternalTriggerBridge>,
12601        context: crate::txn::TxnCommitContext,
12602        control: Option<&crate::ExecutionControl>,
12603        mut before_commit: Option<&mut dyn FnMut() -> Result<()>>,
12604    ) -> Result<(Epoch, Vec<RowId>)> {
12605        use crate::memtable::Row;
12606        use crate::txn::{Staged, StagedOp, WriteKey};
12607        use crate::wal::Op;
12608        use std::collections::hash_map::DefaultHasher;
12609        use std::hash::{Hash, Hasher};
12610        use std::sync::atomic::Ordering;
12611
12612        if txn_id == crate::wal::SYSTEM_TXN_ID {
12613            return Err(MongrelError::Full(
12614                "per-open transaction id namespace exhausted; reopen the database".into(),
12615            ));
12616        }
12617        if self.read_only {
12618            return Err(MongrelError::ReadOnlyReplica);
12619        }
12620        commit_prepare_checkpoint(control, 0)?;
12621        let observed_security_version = self.security_coordinator.version.load(Ordering::Acquire);
12622        self.refresh_security_catalog_if_stale(observed_security_version)?;
12623        let trigger_binding = trigger_catalog_binding(&self.catalog.read());
12624        if self.auth_state.require_auth() && security_principal.is_none() {
12625            return Err(MongrelError::AuthRequired);
12626        }
12627        {
12628            let catalog = self.catalog.read();
12629            if principal_catalog_bound
12630                || security_principal
12631                    .as_ref()
12632                    .is_some_and(|principal| principal.user_id != 0)
12633            {
12634                let principal = security_principal
12635                    .as_ref()
12636                    .ok_or(MongrelError::AuthRequired)?;
12637                security_principal =
12638                    Self::resolve_bound_principal_from_catalog(&catalog, principal);
12639                if security_principal.is_none() {
12640                    return Err(MongrelError::AuthRequired);
12641                }
12642            }
12643        }
12644        let _replication_guard = self.replication_barrier.read();
12645        if self.poisoned.load(Ordering::Relaxed) {
12646            return Err(MongrelError::Other(
12647                "database poisoned by fsync error".into(),
12648            ));
12649        }
12650        // S1A-004: admit the commit as one core operation (rejects once the
12651        // core is draining, closing, closed, or lifecycle-poisoned; the legacy
12652        // fsync-poison error above still wins for a WAL-poisoned core).
12653        let _operation = self.admit_operation()?;
12654
12655        // ── S1B-003: user transactions hold the schema barrier Shared for the
12656        // whole commit so DDL (Exclusive) excludes concurrent DML. Internal
12657        // commits (catalog backfills, external-table state — `context.state`
12658        // is `None`) skip it: they run under their DDL operation's own
12659        // Exclusive hold, and acquiring a second, differently-keyed hold here
12660        // would self-deadlock. The guard releases every lock this commit
12661        // acquired — schema barrier, unique claims, sequence barriers, FK
12662        // holds — on every exit path (S1B-004 step 12).
12663        if context.state.is_some() {
12664            self.acquire_txn_lock(
12665                txn_id,
12666                crate::locks::LockKey::schema_barrier(),
12667                crate::locks::LockMode::Shared,
12668                control,
12669            )?;
12670        }
12671        let _txn_lock_guard = TxnLockGuard {
12672            locks: &self.lock_manager,
12673            txn_id,
12674        };
12675
12676        // ── S1B-005: idempotency check BEFORE the proposal. A repeated key
12677        // with an identical request replays the original receipt without
12678        // re-executing; a different request under the same key conflicts; a
12679        // new key is reserved durably before any WAL record can become
12680        // durable, so a crash mid-commit fails closed on retry.
12681        let idempotency_request = match &context.idempotency {
12682            Some(request) => match self.idempotency.check_and_reserve(request)? {
12683                crate::txn::IdempotencyCheck::Replay(receipt) => {
12684                    let epoch = Epoch(receipt.log_position.index);
12685                    if let Some(state) = &context.state {
12686                        state.committed(receipt);
12687                    }
12688                    return Ok((epoch, Vec::new()));
12689                }
12690                crate::txn::IdempotencyCheck::Reserved => Some(request.clone()),
12691            },
12692            None => None,
12693        };
12694        // Release the reservation on every pre-receipt failure path.
12695        let mut idempotency_guard = idempotency_request.as_ref().map(|request| {
12696            crate::txn::IdempotencyReservationGuard::new(&self.idempotency, request.clone())
12697        });
12698        let mut external_states = dedup_external_states(external_states);
12699        if !external_states.is_empty() {
12700            let cat = self.catalog.read();
12701            for (name, _) in &external_states {
12702                if !cat.external_tables.iter().any(|entry| entry.name == *name) {
12703                    return Err(MongrelError::NotFound(format!(
12704                        "external table {name:?} not found"
12705                    )));
12706                }
12707            }
12708        }
12709        let prepared_materialized_views = {
12710            let mut deduplicated = HashMap::new();
12711            for (definition_index, definition) in materialized_view_updates.into_iter().enumerate()
12712            {
12713                commit_prepare_checkpoint(control, definition_index)?;
12714                if definition.name.is_empty() || definition.query.trim().is_empty() {
12715                    return Err(MongrelError::InvalidArgument(
12716                        "materialized view name and query must not be empty".into(),
12717                    ));
12718                }
12719                deduplicated.insert(definition.name.clone(), definition);
12720            }
12721            let catalog = self.catalog.read();
12722            let mut prepared = Vec::with_capacity(deduplicated.len());
12723            for (definition_index, definition) in deduplicated.into_values().enumerate() {
12724                commit_prepare_checkpoint(control, definition_index)?;
12725                let table_id = catalog
12726                    .live(&definition.name)
12727                    .ok_or_else(|| {
12728                        MongrelError::NotFound(format!(
12729                            "materialized view table {:?} not found",
12730                            definition.name
12731                        ))
12732                    })?
12733                    .table_id;
12734                prepared.push((table_id, definition));
12735            }
12736            prepared.sort_by(|left, right| left.1.name.cmp(&right.1.name));
12737            prepared
12738        };
12739
12740        // ── 1. Prepare: fill generated values, expand triggers, validate, then
12741        // derive write keys from the final atomic write set.
12742        self.fill_auto_increment_for_staging(txn_id, &mut staging, control)?;
12743        self.expand_table_triggers(
12744            txn_id,
12745            &mut staging,
12746            read_epoch,
12747            external_trigger_bridge,
12748            &mut external_states,
12749            control,
12750        )?;
12751        self.fill_auto_increment_for_staging(txn_id, &mut staging, control)?;
12752        external_states = dedup_external_states(external_states);
12753        let expected_external_generations = {
12754            let catalog = self.catalog.read();
12755            let mut generations = HashMap::with_capacity(external_states.len());
12756            for (name, _) in &external_states {
12757                let entry = catalog
12758                    .external_tables
12759                    .iter()
12760                    .find(|entry| entry.name == *name)
12761                    .ok_or_else(|| {
12762                        MongrelError::NotFound(format!("external table {name:?} not found"))
12763                    })?;
12764                generations.insert(name.clone(), entry.created_epoch);
12765            }
12766            generations
12767        };
12768
12769        // S1B-003: claim every unique key this transaction inserts (primary
12770        // keys and declared UNIQUE constraints) in Exclusive mode BEFORE
12771        // validation reads. Concurrent inserts of the same key serialize on
12772        // the claim instead of racing to a write-write conflict; the
12773        // first-committer-wins index below remains the safety net.
12774        self.acquire_unique_key_claims(txn_id, &staging, control)?;
12775        // Fail unauthorized source writes before constraint expansion reads
12776        // existing rows. Constraint-generated writes are checked again inside
12777        // `validate_constraints` before any embedding provider is invoked.
12778        self.validate_write_permissions(&staging, security_principal.as_ref(), control)?;
12779        self.validate_security_writes(&staging, read_epoch, security_principal.as_ref(), control)?;
12780        // Validate declarative constraints (unique / FK / check) under the read
12781        // snapshot, outside the WAL mutex. Trigger-produced writes are included
12782        // here, so the batch either satisfies every declared constraint or is
12783        // rejected atomically. This also materializes generated vectors after
12784        // all trigger and constraint-action writes exist, but before WAL append.
12785        self.validate_constraints(
12786            txn_id,
12787            &mut staging,
12788            read_epoch,
12789            security_principal.as_ref(),
12790            control,
12791        )?;
12792        let mut normalized = Vec::with_capacity(staging.len() * 2);
12793        for (staged_index, (table_id, op)) in staging.into_iter().enumerate() {
12794            commit_prepare_checkpoint(control, staged_index)?;
12795            match op {
12796                crate::txn::Staged::Update {
12797                    row_id,
12798                    new_row: cells,
12799                    ..
12800                } => {
12801                    normalized.push((table_id, crate::txn::Staged::Delete(row_id)));
12802                    normalized.push((table_id, crate::txn::Staged::Put(cells)));
12803                }
12804                op => normalized.push((table_id, op)),
12805            }
12806        }
12807        staging = normalized;
12808        let has_changes = !staging.is_empty()
12809            || !external_states.is_empty()
12810            || !prepared_materialized_views.is_empty();
12811        let truncated_tables: HashSet<u64> = staging
12812            .iter()
12813            .filter_map(|(table_id, op)| matches!(op, Staged::Truncate).then_some(*table_id))
12814            .collect();
12815
12816        let write_keys = {
12817            let cat = self.catalog.read();
12818            let mut keys: Vec<WriteKey> = Vec::new();
12819            for (staged_index, (table_id, staged)) in staging.iter().enumerate() {
12820                commit_prepare_checkpoint(control, staged_index)?;
12821                match staged {
12822                    Staged::Put(cells) => {
12823                        if let Some(entry) = cat.tables.iter().find(|t| t.table_id == *table_id) {
12824                            for col in &entry.schema.columns {
12825                                if col.flags.contains(crate::schema::ColumnFlags::PRIMARY_KEY) {
12826                                    if let Some((_, val)) =
12827                                        cells.iter().find(|(id, _)| *id == col.id)
12828                                    {
12829                                        let mut h = DefaultHasher::new();
12830                                        val.encode_key().hash(&mut h);
12831                                        keys.push(WriteKey::Unique {
12832                                            table_id: *table_id,
12833                                            index_id: 0,
12834                                            key_hash: h.finish(),
12835                                        });
12836                                    }
12837                                }
12838                            }
12839                            // Declared non-PK unique constraints register a
12840                            // `WriteKey::Unique` (namespace-separated from the
12841                            // PK's index_id==0 by setting the high bit) so two
12842                            // concurrent transactions inserting the same key
12843                            // cannot both commit. Rows with any NULL constrained
12844                            // column are skipped (SQL semantics).
12845                            for uc in &entry.schema.constraints.uniques {
12846                                if let Some(key_bytes) = crate::constraint::encode_composite_key(
12847                                    &uc.columns,
12848                                    &cells.iter().cloned().collect(),
12849                                ) {
12850                                    let mut h = DefaultHasher::new();
12851                                    key_bytes.hash(&mut h);
12852                                    keys.push(WriteKey::Unique {
12853                                        table_id: *table_id,
12854                                        index_id: uc.id | 0x8000,
12855                                        key_hash: h.finish(),
12856                                    });
12857                                }
12858                            }
12859                        }
12860                    }
12861                    Staged::Delete(rid) => keys.push(WriteKey::Row {
12862                        table_id: *table_id,
12863                        row_id: rid.0,
12864                    }),
12865                    Staged::Truncate => keys.push(WriteKey::Table {
12866                        table_id: *table_id,
12867                    }),
12868                    Staged::Update { .. } => {
12869                        return Err(MongrelError::Other(
12870                            "transaction contains an unnormalized update during preparation".into(),
12871                        ));
12872                    }
12873                }
12874            }
12875            for (external_index, (name, _)) in external_states.iter().enumerate() {
12876                commit_prepare_checkpoint(control, external_index)?;
12877                let mut h = DefaultHasher::new();
12878                name.hash(&mut h);
12879                keys.push(WriteKey::Unique {
12880                    table_id: EXTERNAL_TABLE_ID,
12881                    index_id: 0,
12882                    key_hash: h.finish(),
12883                });
12884            }
12885            keys
12886        };
12887
12888        // Opportunistic pruning.
12889        let min_active = self.active_txns.min_read_epoch();
12890        if min_active < u64::MAX {
12891            self.conflicts.prune_below(Epoch(min_active));
12892        }
12893
12894        // S1B-002 (Serializable): SSI certification keys — every tracked point
12895        // read as a row key, every tracked predicate/range read as a table
12896        // key. A concurrent commit covering any of them after this
12897        // transaction's read epoch is an rw-antidependency dangerous
12898        // structure; certification aborts rather than allowing a
12899        // non-serializable interleaving.
12900        let ssi_keys = match context.isolation.canonical() {
12901            crate::txn::IsolationLevel::Serializable => {
12902                crate::txn::ssi_validation_keys(&context.read_set, &context.predicate_set)
12903            }
12904            _ => Vec::new(),
12905        };
12906
12907        // ── 1a. Pre-validate the full write-set OUTSIDE the sequencer (spec
12908        // §8.5, review fix #17). Snapshot the conflict-index version so the
12909        // sequencer only re-checks if new commits arrived in the interim.
12910        if self.conflicts.conflicts(&write_keys, read_epoch) {
12911            return Err(MongrelError::Conflict(
12912                "write-write conflict (pre-validate, first-committer-wins)".into(),
12913            ));
12914        }
12915        if !ssi_keys.is_empty() && self.conflicts.conflicts(&ssi_keys, read_epoch) {
12916            return Err(MongrelError::SerializationFailure {
12917                message: "a concurrent commit invalidated this transaction's reads (pre-validate)"
12918                    .into(),
12919            });
12920        }
12921        let pre_validate_version = self.conflicts.version();
12922
12923        // ── 1b. Spill: if a table's staged puts exceed the threshold, write a
12924        // uniform-epoch pending run (spec §8.5). Rows in the run are NOT
12925        // streamed as Put records; they are linked at publish time.
12926        let mut spilled: Vec<SpilledRun> = Vec::new();
12927        let mut spilled_tables: std::collections::HashSet<u64> = std::collections::HashSet::new();
12928        // Protect this txn's `_txn/<id>/` dir from a concurrent `gc()` for as long
12929        // as the spill runs are live (registered on first spill, dropped at the
12930        // end of this function on commit/abort/error).
12931        let mut spill_guard: Option<crate::retention::SpillGuard> = None;
12932        {
12933            let mut table_bytes: HashMap<u64, u64> = HashMap::new();
12934            let mut put_indexes: HashMap<u64, Vec<usize>> = HashMap::new();
12935            for (staged_index, (table_id, staged)) in staging.iter().enumerate() {
12936                commit_prepare_checkpoint(control, staged_index)?;
12937                if let Staged::Put(cells) = staged {
12938                    let bytes = cells.iter().fold(32_u64, |bytes, (_, value)| {
12939                        bytes.saturating_add(value.estimated_bytes())
12940                    });
12941                    let table_bytes = table_bytes.entry(*table_id).or_default();
12942                    *table_bytes = table_bytes.saturating_add(bytes);
12943                    put_indexes.entry(*table_id).or_default().push(staged_index);
12944                }
12945            }
12946            let tables = self.tables.read();
12947            for (table_index, (&table_id, &bytes)) in table_bytes.iter().enumerate() {
12948                commit_prepare_checkpoint(control, table_index)?;
12949                if bytes
12950                    <= self
12951                        .spill_threshold
12952                        .load(std::sync::atomic::Ordering::Relaxed)
12953                {
12954                    continue;
12955                }
12956                let Some(handle) = tables.get(&table_id) else {
12957                    continue;
12958                };
12959                spill_guard.get_or_insert_with(|| self.active_spills.register(txn_id));
12960                let mut t = handle.lock();
12961                let tdir = t.table_dir().to_path_buf();
12962                let txn_dir = tdir.join("_txn").join(txn_id.to_string());
12963                std::fs::create_dir_all(&txn_dir)?;
12964                let run_id = t.alloc_run_id()? as u128;
12965                let pending_path = txn_dir.join(format!("r-{run_id}.sr"));
12966                let final_path = t.run_path(run_id as u64);
12967
12968                let mut rows: Vec<Row> = Vec::new();
12969                for (put_index, staged_index) in put_indexes[&table_id].iter().enumerate() {
12970                    commit_prepare_checkpoint(control, put_index)?;
12971                    let Staged::Put(cells) = &mut staging[*staged_index].1 else {
12972                        return Err(MongrelError::Other(
12973                            "transaction put index no longer references a put".into(),
12974                        ));
12975                    };
12976                    t.validate_cells_not_null(cells)?;
12977                    let row_id = t.alloc_row_id()?;
12978                    let mut row = Row::new(row_id, Epoch(0));
12979                    row.columns.extend(std::mem::take(cells));
12980                    rows.push(row);
12981                }
12982                let schema = t.schema_ref().clone();
12983                let kek = t.kek_ref().cloned();
12984                let specs = t.indexable_column_specs();
12985                drop(t);
12986
12987                let mut writer = crate::sorted_run::RunWriter::new(&schema, run_id, Epoch(0), 0)
12988                    .uniform_epoch(true);
12989                if let Some(ref kek) = kek {
12990                    writer = writer.with_encryption(kek.as_ref(), specs);
12991                }
12992                commit_prepare_checkpoint(control, 0)?;
12993                let header = writer.write(&pending_path, &rows)?;
12994                commit_prepare_checkpoint(control, 0)?;
12995                let row_count = header.row_count;
12996                let min_rid = rows.first().map(|r| r.row_id.0).unwrap_or(0);
12997                let max_rid = rows.last().map(|r| r.row_id.0).unwrap_or(0);
12998
12999                spilled.push(SpilledRun {
13000                    table_id,
13001                    run_id,
13002                    pending_path,
13003                    final_path,
13004                    rows,
13005                    row_count,
13006                    min_rid,
13007                    max_rid,
13008                    content_hash: header.content_hash,
13009                });
13010                spilled_tables.insert(table_id);
13011            }
13012        }
13013
13014        // Test seam: let a test race `gc()` against this in-flight spill.
13015        if spill_guard.is_some() {
13016            if let Some(hook) = self.spill_hook.lock().as_ref() {
13017                hook();
13018            }
13019        }
13020
13021        // ── 1c. Pre-build non-spilled put rows OUTSIDE the WAL critical section.
13022        // Allocating row ids + building the rows here (lock order: table handle →
13023        // nothing) means the sequencer never locks a table handle while holding
13024        // the shared-WAL mutex. That matters because `Table::commit`/`flush` lock
13025        // the table handle THEN the shared WAL; if the sequencer did the reverse
13026        // (WAL then handle) the two paths would deadlock (review fix: B1).
13027        // Aligned 1:1 with `staging`; `None` for deletes and spilled puts.
13028        // Row ids are allocated here, before the sequencer's delta conflict
13029        // re-check, so a losing txn leaks the ids it reserved — harmless, the
13030        // u64 row-id space is monotonic and gaps are expected (spills do the same).
13031        let mut prebuilt: Vec<Option<Row>> = std::iter::repeat_with(|| None)
13032            .take(staging.len())
13033            .collect();
13034        let mut delete_images: Vec<Option<Row>> = std::iter::repeat_with(|| None)
13035            .take(staging.len())
13036            .collect();
13037        {
13038            let mut indexes_by_table: HashMap<u64, Vec<usize>> = HashMap::new();
13039            for (index, (table_id, staged)) in staging.iter().enumerate() {
13040                commit_prepare_checkpoint(control, index)?;
13041                if matches!(staged, Staged::Delete(_))
13042                    || matches!(staged, Staged::Put(_) if !spilled_tables.contains(table_id))
13043                {
13044                    indexes_by_table.entry(*table_id).or_default().push(index);
13045                }
13046            }
13047            let tables = self.tables.read();
13048            for (table_index, (table_id, indexes)) in indexes_by_table.into_iter().enumerate() {
13049                commit_prepare_checkpoint(control, table_index)?;
13050                let handle = tables.get(&table_id).ok_or_else(|| {
13051                    MongrelError::NotFound(format!("table {table_id} not mounted"))
13052                })?;
13053                #[cfg(test)]
13054                PREBUILD_TABLE_LOCKS.with(|count| count.set(count.get() + 1));
13055                let mut t = handle.lock();
13056                for (prepare_index, index) in indexes.into_iter().enumerate() {
13057                    commit_prepare_checkpoint(control, prepare_index)?;
13058                    match &staging[index].1 {
13059                        Staged::Put(cells) if !spilled_tables.contains(&table_id) => {
13060                            t.validate_cells_not_null(cells)?;
13061                            let mut row = Row::new(t.alloc_row_id()?, Epoch(0));
13062                            for (column, value) in cells {
13063                                row.columns.insert(*column, value.clone());
13064                            }
13065                            prebuilt[index] = Some(row);
13066                        }
13067                        Staged::Delete(row_id) => {
13068                            delete_images[index] =
13069                                t.get(*row_id, self.snapshot_for_epoch(read_epoch));
13070                        }
13071                        Staged::Put(_) | Staged::Truncate => {}
13072                        Staged::Update { .. } => {
13073                            return Err(MongrelError::Other(
13074                                "transaction contains an unnormalized update during row preparation"
13075                                    .into(),
13076                            ));
13077                        }
13078                    }
13079                }
13080            }
13081        }
13082
13083        // Finish every fallible index read before the commit marker can become
13084        // durable. Post-durable row/run metadata application is then entirely
13085        // in-memory and cannot stop halfway through a multi-table publish.
13086        let prepared_table_handles = {
13087            let table_ids: HashSet<u64> = staging.iter().map(|(table_id, _)| *table_id).collect();
13088            let put_table_ids: HashSet<u64> = staging
13089                .iter()
13090                .filter_map(|(table_id, staged)| {
13091                    matches!(staged, Staged::Put(_)).then_some(*table_id)
13092                })
13093                .collect();
13094            let tables = self.tables.read();
13095            let mut handles = HashMap::with_capacity(table_ids.len());
13096            for (table_index, table_id) in table_ids.into_iter().enumerate() {
13097                commit_prepare_checkpoint(control, table_index)?;
13098                let handle = tables.get(&table_id).ok_or_else(|| {
13099                    MongrelError::NotFound(format!("table {table_id} not mounted"))
13100                })?;
13101                if put_table_ids.contains(&table_id) {
13102                    match control {
13103                        Some(control) => {
13104                            handle.lock().prepare_durable_publish_controlled(control)?
13105                        }
13106                        None => handle.lock().prepare_durable_publish()?,
13107                    }
13108                }
13109                handles.insert(table_id, handle.clone());
13110            }
13111            handles
13112        };
13113
13114        // Link large-transaction spill files before WAL durability. The guard
13115        // restores their pending names on every error before WAL append begins;
13116        // publication only attaches already-present files in memory.
13117        let mut prepared_run_links = PreparedRunLinks::prepare(&spilled)?;
13118
13119        let mut spilled_row_ids: HashMap<u64, VecDeque<RowId>> = spilled
13120            .iter()
13121            .map(|run| {
13122                (
13123                    run.table_id,
13124                    run.rows.iter().map(|row| row.row_id).collect(),
13125                )
13126            })
13127            .collect();
13128        let committed_row_ids = staging
13129            .iter()
13130            .enumerate()
13131            .filter_map(|(index, (table_id, staged))| {
13132                if !matches!(staged, Staged::Put(_)) {
13133                    return None;
13134                }
13135                prebuilt[index].as_ref().map(|row| row.row_id).or_else(|| {
13136                    spilled_row_ids
13137                        .get_mut(table_id)
13138                        .and_then(VecDeque::pop_front)
13139                })
13140            })
13141            .collect();
13142
13143        let mut prepared_external = Vec::with_capacity(external_states.len());
13144        for (external_index, (name, state)) in external_states.iter().enumerate() {
13145            commit_prepare_checkpoint(control, external_index)?;
13146            let pending = prepare_external_state_file(&self.root, name, state, txn_id)?;
13147            prepared_external.push((name.clone(), state.clone(), pending));
13148        }
13149
13150        // ── 2. Sequencer: validate-first → assign → append → sync → record ──
13151        //
13152        // The schema barrier's Shared hold covered the prepare/validate phases
13153        // above (every catalog, constraint, and claim read ran under a stable
13154        // schema); the publish below is guarded by the catalog-generation
13155        // check, exactly as before S1B-003. Release the barrier here — before
13156        // the sequencer — so a DDL waiting on its Exclusive hold may proceed
13157        // while this commit publishes (the generation check resolves that
13158        // race). Unique/sequence/FK claims stay held until the commit ends.
13159        if context.state.is_some() {
13160            self.lock_manager
13161                .release(txn_id, &crate::locks::LockKey::schema_barrier());
13162        }
13163        let added_runs: Vec<crate::wal::AddedRun> = spilled
13164            .iter()
13165            .map(|s| crate::wal::AddedRun {
13166                table_id: s.table_id,
13167                run_id: s.run_id,
13168                row_count: s.row_count,
13169                level: 0,
13170                min_row_id: s.min_rid,
13171                max_row_id: s.max_rid,
13172                content_hash: s.content_hash,
13173            })
13174            .collect();
13175        if let Some(hook) = self.catalog_commit_hook.lock().as_ref() {
13176            hook();
13177        }
13178        // Lock order: security gate -> commit lock -> shared WAL -> table locks.
13179        // Security mutations cannot overtake an authorized commit before its
13180        // commit marker is durable.
13181        let security_guard = self.security_coordinator.gate.read();
13182        if self.security_coordinator.version.load(Ordering::Acquire) != observed_security_version {
13183            return Err(MongrelError::Conflict(
13184                "security policy changed during write".into(),
13185            ));
13186        }
13187        if spill_guard.is_some() {
13188            if let Some(hook) = self.security_commit_hook.lock().as_ref() {
13189                hook();
13190            }
13191        }
13192        let commit_guard = self.commit_lock.lock();
13193        let catalog_generation_result = (|| {
13194            {
13195                let catalog = self.catalog.read();
13196                for table_id in prepared_table_handles.keys() {
13197                    let is_current = catalog.tables.iter().any(|entry| {
13198                        entry.table_id == *table_id
13199                            && matches!(entry.state, TableState::Live | TableState::Building { .. })
13200                    });
13201                    if !is_current {
13202                        return Err(MongrelError::Conflict(format!(
13203                            "table {table_id} changed during transaction preparation"
13204                        )));
13205                    }
13206                }
13207                for (name, created_epoch) in &expected_external_generations {
13208                    let current = catalog
13209                        .external_tables
13210                        .iter()
13211                        .find(|entry| entry.name == *name)
13212                        .map(|entry| entry.created_epoch);
13213                    if current != Some(*created_epoch) {
13214                        return Err(MongrelError::Conflict(format!(
13215                            "external table {name:?} changed during transaction preparation"
13216                        )));
13217                    }
13218                }
13219                for (table_id, definition) in &prepared_materialized_views {
13220                    let current = catalog.live(&definition.name).map(|entry| entry.table_id);
13221                    if current != Some(*table_id) {
13222                        return Err(MongrelError::Conflict(format!(
13223                            "materialized view {:?} changed during transaction preparation",
13224                            definition.name
13225                        )));
13226                    }
13227                }
13228                if trigger_catalog_binding(&catalog) != trigger_binding {
13229                    return Err(MongrelError::Conflict(
13230                        "trigger or referenced table generation changed during transaction preparation"
13231                            .into(),
13232                    ));
13233                }
13234            }
13235            let tables = self.tables.read();
13236            for (table_id, prepared) in &prepared_table_handles {
13237                if !tables
13238                    .get(table_id)
13239                    .is_some_and(|current| current.ptr_eq(prepared))
13240                {
13241                    return Err(MongrelError::Conflict(format!(
13242                        "table {table_id} mount changed during transaction preparation"
13243                    )));
13244                }
13245            }
13246            Ok(())
13247        })();
13248        if let Err(error) = catalog_generation_result {
13249            drop(commit_guard);
13250            for (_, _, pending) in &prepared_external {
13251                let _ = std::fs::remove_file(pending);
13252            }
13253            return Err(error);
13254        }
13255        // The commit lock keeps the next epoch stable while logical spill
13256        // records are serialized. Build them before taking the shared WAL
13257        // lock, and cap their aggregate memory/WAL footprint.
13258        let new_epoch = self.epoch.assigned().next();
13259        // S1B-004 step 5 / P0.5: allocate the commit HLC under the sequencer
13260        // lock *before* durable row payloads are encoded so every Put /
13261        // SpilledRows version carries the same commit_ts as the receipt.
13262        // Spec §8.2: strictly greater than every participant read/write
13263        // timestamp captured at `begin`.
13264        let commit_ts = match context.read_ts {
13265            Some(read_ts) => self.hlc.commit_timestamp([read_ts]),
13266            None => self.hlc.now().map_err(|skew| {
13267                MongrelError::Other(format!(
13268                    "clock skew rejected commit timestamp allocation: {skew}"
13269                ))
13270            })?,
13271        };
13272        let mut spilled_wal_bytes = 0;
13273        let mut spilled_wal_records = Vec::<(u64, Op)>::new();
13274        let spill_prepare = (|| {
13275            for run in &mut spilled {
13276                for row in &mut run.rows {
13277                    row.committed_epoch = new_epoch;
13278                    row.commit_ts = Some(commit_ts);
13279                }
13280                for rows in encode_spilled_row_chunks(
13281                    &run.rows,
13282                    &mut spilled_wal_bytes,
13283                    SPILLED_WAL_TOTAL_MAX_BYTES,
13284                    control,
13285                )? {
13286                    spilled_wal_records.push((
13287                        run.table_id,
13288                        Op::SpilledRows {
13289                            table_id: run.table_id,
13290                            rows,
13291                        },
13292                    ));
13293                }
13294            }
13295            Result::<()>::Ok(())
13296        })();
13297        if let Err(error) = spill_prepare {
13298            for (_, _, pending) in &prepared_external {
13299                let _ = std::fs::remove_file(pending);
13300            }
13301            return Err(error);
13302        }
13303        let (
13304            new_epoch,
13305            mut _epoch_guard,
13306            applies,
13307            committed_materialized_views,
13308            commit_seq,
13309            commit_ts,
13310        ) = {
13311            let mut wal = self.shared_wal.lock();
13312
13313            // Re-check only if the conflict index advanced since pre-validation
13314            // (bounded delta — spec §8.5, review fix #17). If the version is
13315            // unchanged, the pre-check result is still valid and the sequencer
13316            // does O(1) work regardless of write-set size.
13317            if self.conflicts.version() != pre_validate_version {
13318                if self.conflicts.conflicts(&write_keys, read_epoch) {
13319                    // Abort: this txn assigned no epoch yet. The prepared-run guard
13320                    // restores final run names to their pending paths on return.
13321                    drop(wal);
13322                    for (_, _, pending) in &prepared_external {
13323                        let _ = std::fs::remove_file(pending);
13324                    }
13325                    return Err(MongrelError::Conflict(
13326                        "write-write conflict (sequencer delta re-check)".into(),
13327                    ));
13328                }
13329                if !ssi_keys.is_empty() && self.conflicts.conflicts(&ssi_keys, read_epoch) {
13330                    // S1B-002 dangerous structure: a commit that landed between
13331                    // the pre-check and the sequencer invalidated a tracked
13332                    // read. Abort before assigning any epoch.
13333                    drop(wal);
13334                    for (_, _, pending) in &prepared_external {
13335                        let _ = std::fs::remove_file(pending);
13336                    }
13337                    return Err(MongrelError::SerializationFailure {
13338                        message:
13339                            "a concurrent commit invalidated this transaction's reads (sequencer re-check)"
13340                                .into(),
13341                    });
13342                }
13343            }
13344
13345            if let Some(control) = control {
13346                if let Err(error) = control.checkpoint() {
13347                    drop(wal);
13348                    for (_, _, pending) in &prepared_external {
13349                        let _ = std::fs::remove_file(pending);
13350                    }
13351                    return Err(error);
13352                }
13353            }
13354            let mut applies = Vec::<TableApplyBatch>::new();
13355            let mut apply_indexes = HashMap::<u64, usize>::new();
13356            let mut committed_materialized_views = Vec::new();
13357            let mut wal_records = spilled_wal_records;
13358
13359            let mut index = 0;
13360            while index < staging.len() {
13361                let table_id = staging[index].0;
13362                let handle = prepared_table_handles
13363                    .get(&table_id)
13364                    .cloned()
13365                    .ok_or_else(|| {
13366                        MongrelError::NotFound(format!("table {table_id} not prepared"))
13367                    })?;
13368                let batch_index = *apply_indexes.entry(table_id).or_insert_with(|| {
13369                    let index = applies.len();
13370                    applies.push(TableApplyBatch {
13371                        table_id,
13372                        handle,
13373                        ops: Vec::new(),
13374                    });
13375                    index
13376                });
13377
13378                // Skip puts for tables that were spilled — their data is in a
13379                // pending run, not in streamed Put records.
13380                if spilled_tables.contains(&table_id) && matches!(&staging[index].1, Staged::Put(_))
13381                {
13382                    index += 1;
13383                    continue;
13384                }
13385
13386                match &staging[index].1 {
13387                    Staged::Put(_) => {
13388                        let mut rows = Vec::new();
13389                        while index < staging.len()
13390                            && staging[index].0 == table_id
13391                            && matches!(&staging[index].1, Staged::Put(_))
13392                        {
13393                            let mut row = prebuilt[index].take().ok_or_else(|| {
13394                                MongrelError::Other(
13395                                    "transaction prepare lost a prebuilt put row".into(),
13396                                )
13397                            })?;
13398                            row.committed_epoch = new_epoch;
13399                            row.commit_ts = Some(commit_ts);
13400                            rows.push(row);
13401                            index += 1;
13402                        }
13403                        let payload = bincode::serialize(&rows)
13404                            .map_err(|e| MongrelError::Other(format!("row serialize: {e}")))?;
13405                        wal_records.push((
13406                            table_id,
13407                            Op::Put {
13408                                table_id,
13409                                rows: payload,
13410                            },
13411                        ));
13412                        applies[batch_index].ops.push(StagedOp::Put(rows));
13413                    }
13414                    Staged::Delete(_) => {
13415                        let mut row_ids = Vec::new();
13416                        while index < staging.len()
13417                            && staging[index].0 == table_id
13418                            && matches!(&staging[index].1, Staged::Delete(_))
13419                        {
13420                            let Staged::Delete(row_id) = &staging[index].1 else {
13421                                return Err(MongrelError::Other(
13422                                    "transaction delete batch changed during WAL preparation"
13423                                        .into(),
13424                                ));
13425                            };
13426                            if let Some(before) = &delete_images[index] {
13427                                wal_records.push((
13428                                    table_id,
13429                                    Op::BeforeImage {
13430                                        table_id,
13431                                        row_id: *row_id,
13432                                        row: bincode::serialize(before).map_err(|error| {
13433                                            MongrelError::Other(format!(
13434                                                "before-image serialize: {error}"
13435                                            ))
13436                                        })?,
13437                                    },
13438                                ));
13439                            }
13440                            row_ids.push(*row_id);
13441                            index += 1;
13442                        }
13443                        wal_records.push((
13444                            table_id,
13445                            Op::Delete {
13446                                table_id,
13447                                row_ids: row_ids.clone(),
13448                            },
13449                        ));
13450                        applies[batch_index].ops.push(StagedOp::Delete(row_ids));
13451                    }
13452                    Staged::Truncate => {
13453                        wal_records.push((table_id, Op::TruncateTable { table_id }));
13454                        applies[batch_index].ops.push(StagedOp::Truncate);
13455                        index += 1;
13456                    }
13457                    Staged::Update { .. } => {
13458                        return Err(MongrelError::Other(
13459                            "transaction contains an unnormalized update at the sequencer".into(),
13460                        ));
13461                    }
13462                }
13463            }
13464
13465            for (name, state, _) in &prepared_external {
13466                wal_records.push((
13467                    EXTERNAL_TABLE_ID,
13468                    Op::ExternalTableState {
13469                        name: name.clone(),
13470                        state: state.clone(),
13471                    },
13472                ));
13473            }
13474
13475            for (table_id, definition) in &prepared_materialized_views {
13476                let mut definition = definition.clone();
13477                definition.last_refresh_epoch = new_epoch.0;
13478                wal_records.push((
13479                    *table_id,
13480                    Op::Ddl(crate::wal::DdlOp::SetMaterializedView {
13481                        name: definition.name.clone(),
13482                        definition_json: crate::wal::DdlOp::encode_materialized_view(&definition)?,
13483                    }),
13484                ));
13485                committed_materialized_views.push(definition);
13486            }
13487            if !committed_materialized_views.is_empty() {
13488                let mut next_catalog = self.catalog.read().clone();
13489                for definition in &committed_materialized_views {
13490                    if let Some(existing) = next_catalog
13491                        .materialized_views
13492                        .iter_mut()
13493                        .find(|existing| existing.name == definition.name)
13494                    {
13495                        *existing = definition.clone();
13496                    } else {
13497                        next_catalog.materialized_views.push(definition.clone());
13498                    }
13499                }
13500                next_catalog.db_epoch = next_catalog.db_epoch.max(new_epoch.0);
13501                wal_records.push((
13502                    WAL_TABLE_ID,
13503                    Op::Ddl(crate::wal::DdlOp::CatalogSnapshot {
13504                        catalog_json: crate::wal::DdlOp::encode_catalog(&next_catalog)?,
13505                    }),
13506                ));
13507            }
13508
13509            if let Some(control) = control {
13510                if let Err(error) = control.checkpoint() {
13511                    drop(wal);
13512                    for (_, _, pending) in &prepared_external {
13513                        let _ = std::fs::remove_file(pending);
13514                    }
13515                    return Err(error);
13516                }
13517            }
13518            if let Some(before_commit) = before_commit.as_mut() {
13519                if let Err(error) = before_commit() {
13520                    drop(wal);
13521                    for (_, _, pending) in &prepared_external {
13522                        let _ = std::fs::remove_file(pending);
13523                    }
13524                    return Err(error);
13525                }
13526            }
13527
13528            let assigned_epoch = self.epoch.bump_assigned();
13529            let _epoch_guard = EpochGuard::new(self.epoch.as_ref(), assigned_epoch);
13530            if assigned_epoch != new_epoch {
13531                for (_, _, pending) in &prepared_external {
13532                    let _ = std::fs::remove_file(pending);
13533                }
13534                return Err(MongrelError::Conflict(
13535                    "commit epoch changed while sequencer lock was held".into(),
13536                ));
13537            }
13538
13539            // S1B-004 step 6: enter commit-critical before the proposal can
13540            // become durable. The HLC commit_ts was allocated above under the
13541            // commit lock so every row payload already carries it.
13542            // FND-006: `txn.decision.before` fires immediately before the
13543            // durable commit decision (enter CommitCritical / WAL proposal).
13544            // A Fail aborts while still Preparing — nothing durable yet.
13545            if let Err(fault) = mongreldb_fault::inject("txn.decision.before") {
13546                for (_, _, pending) in &prepared_external {
13547                    let _ = std::fs::remove_file(pending);
13548                }
13549                return Err(crate::commit_log::fault_as_io(fault));
13550            }
13551            if let Some(state) = &context.state {
13552                state.enter_commit_critical();
13553            }
13554
13555            // From this point the outcome can become ambiguous. Keep prepared
13556            // spill files at the final names referenced by a possibly durable
13557            // commit marker; orphan cleanup is safe when the append did fail.
13558            prepared_run_links.disarm();
13559
13560            // FND-004 (spec §9.4): the commit log owns the append step for the
13561            // transaction command (records + commit marker). The commit path
13562            // proposes through it; durability and the receipt follow below.
13563            let commit_seq = self
13564                .standalone_commit_log
13565                .append_transaction(
13566                    &mut wal,
13567                    txn_id,
13568                    new_epoch,
13569                    commit_ts,
13570                    wal_records,
13571                    &added_runs,
13572                )
13573                .map_err(|error| self.commit_outcome_unknown(new_epoch, error))?;
13574
13575            // Record the conflict + assign the epoch under the WAL lock so commit
13576            // order == WAL append order, but DO NOT fsync here (P3.2): the fsync
13577            // moves out of this critical section to the group-commit coordinator
13578            // so concurrent committers share a single leader fsync.
13579            self.conflicts.record(&write_keys, new_epoch);
13580            (
13581                new_epoch,
13582                _epoch_guard,
13583                applies,
13584                committed_materialized_views,
13585                commit_seq,
13586                commit_ts,
13587            )
13588        };
13589        drop(commit_guard);
13590
13591        // ── 2b. Durability: one leader fsync serves this whole batch (P3.2). ──
13592        let receipt =
13593            self.await_durable_commit_with_ts(txn_id, commit_seq, new_epoch, commit_ts)?;
13594        drop(security_guard);
13595
13596        // ── S1B-005: record the receipt against the reserved idempotency key
13597        // (durable before the caller can observe success), so a repeated key
13598        // with an identical request replays this receipt.
13599        if let Some(request) = &idempotency_request {
13600            if let Err(error) =
13601                self.idempotency
13602                    .complete(request, txn_id, new_epoch, receipt.commit_ts)
13603            {
13604                return Err(MongrelError::DurableCommit {
13605                    epoch: new_epoch.0,
13606                    message: format!("idempotency record was not durable: {error}"),
13607                });
13608            }
13609            if let Some(guard) = idempotency_guard.as_mut() {
13610                guard.disarm();
13611            }
13612        }
13613
13614        // ── 3. Publish: apply non-spilled ops + link spilled runs ──
13615        let publish_result: Result<()> = {
13616            let mut first_error = None;
13617            let mut spilled_by_table: HashMap<u64, Vec<&SpilledRun>> = HashMap::new();
13618            for run in &spilled {
13619                spilled_by_table.entry(run.table_id).or_default().push(run);
13620            }
13621            let mut modified_tables = Vec::with_capacity(applies.len());
13622            // Apply every table completely before any fallible manifest write.
13623            // The visible epoch remains unchanged until all tables are coherent.
13624            for batch in applies {
13625                #[cfg(test)]
13626                PUBLISH_TABLE_LOCKS.with(|count| count.set(count.get() + 1));
13627                let mut t = batch.handle.lock();
13628                for op in batch.ops {
13629                    match op {
13630                        StagedOp::Put(rows) => t.apply_put_rows_prepared(rows),
13631                        StagedOp::Delete(row_ids) => {
13632                            for row_id in row_ids {
13633                                t.apply_delete_at(row_id, new_epoch, Some(commit_ts));
13634                            }
13635                        }
13636                        StagedOp::Truncate => t.apply_truncate(new_epoch),
13637                    }
13638                }
13639                if let Some(runs) = spilled_by_table.remove(&batch.table_id) {
13640                    for run in runs {
13641                        t.link_run(crate::manifest::RunRef {
13642                            run_id: run.run_id,
13643                            level: 0,
13644                            epoch_created: new_epoch.0,
13645                            row_count: run.row_count,
13646                        });
13647                        t.apply_run_metadata_prepared(&run.rows)?;
13648                        if truncated_tables.contains(&batch.table_id) {
13649                            // TRUNCATE + spilled puts fully describe this table at
13650                            // the commit epoch. Endorse the epoch so clean-reopen
13651                            // recovery does not replay the truncate over the
13652                            // already-linked replacement run.
13653                            t.set_flushed_epoch(new_epoch);
13654                        }
13655                    }
13656                }
13657                t.invalidate_pending_cache();
13658                drop(t);
13659                modified_tables.push(batch.handle);
13660            }
13661
13662            // Checkpoint only after every live table carries the durable state.
13663            // Continue after one checkpoint failure so runtime publication stays
13664            // all-or-nothing; WAL recovery repairs failed files on reopen.
13665            for handle in modified_tables {
13666                #[cfg(test)]
13667                COMMIT_MANIFEST_WRITES.with(|count| count.set(count.get() + 1));
13668                if let Err(error) = handle.lock().persist_manifest(new_epoch) {
13669                    first_error.get_or_insert(error);
13670                }
13671            }
13672            for (name, _, pending) in &prepared_external {
13673                if let Err(error) = publish_external_state_file(&self.root, name, pending) {
13674                    first_error.get_or_insert(error);
13675                }
13676            }
13677            if !committed_materialized_views.is_empty() {
13678                let mut next_catalog = self.catalog.read().clone();
13679                for definition in committed_materialized_views {
13680                    if let Some(existing) = next_catalog
13681                        .materialized_views
13682                        .iter_mut()
13683                        .find(|existing| existing.name == definition.name)
13684                    {
13685                        *existing = definition;
13686                    } else {
13687                        next_catalog.materialized_views.push(definition);
13688                    }
13689                }
13690                next_catalog.db_epoch = next_catalog.db_epoch.max(new_epoch.0);
13691                if let Err(error) = self.checkpoint_catalog_after_durable(next_catalog) {
13692                    first_error.get_or_insert(error);
13693                }
13694            }
13695            match first_error {
13696                Some(error) => Err(error),
13697                None => Ok(()),
13698            }
13699        };
13700
13701        if has_changes {
13702            let _ = self.change_wake.send(());
13703        }
13704        self.finish_durable_publish(new_epoch, &mut _epoch_guard, &receipt, publish_result)?;
13705        // S1B-001: the commit is durable and published — record the receipt on
13706        // the formal state. (Post-publish errors above return `DurableCommit`
13707        // and deliberately leave the state `CommitCritical`.)
13708        if let Some(state) = &context.state {
13709            state.committed(receipt.clone());
13710        }
13711        // FND-006: `txn.decision.after` fires only after the decision is
13712        // durable and the Committed receipt is published. A Fail must not
13713        // undo the decision — surface as DurableCommit (post-fence).
13714        mongreldb_fault::inject("txn.decision.after").map_err(|fault| {
13715            MongrelError::DurableCommit {
13716                epoch: new_epoch.0,
13717                message: fault.to_string(),
13718            }
13719        })?;
13720        Ok((new_epoch, committed_row_ids))
13721    }
13722
13723    /// Build an HLC-authoritative snapshot at the current visible watermark
13724    /// (P0.5). Prefers the durable receipt HLC for the visible epoch; falls
13725    /// back to the live clock so HLC-stamped rows remain comparable.
13726    pub fn visible_snapshot(&self) -> Snapshot {
13727        self.snapshot_for_epoch(self.epoch.visible())
13728    }
13729
13730    /// Build an HLC-pinned snapshot at a specific commit epoch (P0.5).
13731    ///
13732    /// Prefer the durable ledger stamp for `epoch`, then the live HLC clock,
13733    /// then [`HlcTimestamp::MAX`] so HLC-stamped versions remain readable.
13734    pub fn snapshot_for_epoch(&self, epoch: Epoch) -> Snapshot {
13735        let commit_ts = self
13736            .commit_ts_for_epoch(epoch)
13737            .filter(|ts| *ts != mongreldb_types::hlc::HlcTimestamp::ZERO)
13738            .or_else(|| self.hlc.now().ok())
13739            .unwrap_or(mongreldb_types::hlc::HlcTimestamp::MAX);
13740        Snapshot::at_hlc(epoch, commit_ts)
13741    }
13742
13743    /// Register a read snapshot at the current visible epoch + HLC and return
13744    /// it with a guard that retains it for GC until dropped.
13745    pub fn snapshot(&self) -> (Snapshot, SnapshotGuard<'_>) {
13746        let snap = self.visible_snapshot();
13747        let g = self.snapshots.register(snap.epoch);
13748        (snap, g)
13749    }
13750
13751    /// Owned (clonable-handle) variant of [`Self::snapshot`] for cross-thread
13752    /// retention.
13753    pub fn snapshot_owned(&self) -> (Snapshot, OwnedSnapshotGuard) {
13754        let snap = self.visible_snapshot();
13755        let g = self.snapshots.register_owned(snap.epoch);
13756        (snap, g)
13757    }
13758
13759    /// Configure a rolling history window measured in prior commit epochs.
13760    /// The first enable starts at the current epoch because earlier versions
13761    /// may already have been compacted. Increasing the window likewise cannot
13762    /// recreate history that fell outside the previous guarantee.
13763    pub fn set_history_retention_epochs(&self, epochs: u64) -> Result<()> {
13764        let _guard = self.ddl_lock.lock();
13765        let current = self.epoch.visible();
13766        let (old_epochs, old_start) = self.snapshots.history_config();
13767        let earliest_already_guaranteed = if old_epochs == 0 {
13768            current
13769        } else {
13770            Epoch(old_start.0.max(current.0.saturating_sub(old_epochs)))
13771        };
13772        let start = if epochs == 0 {
13773            current
13774        } else {
13775            earliest_already_guaranteed
13776        };
13777        let published = std::cell::Cell::new(false);
13778        let result = write_history_retention(&self.root, epochs, start, || {
13779            self.snapshots.configure_history(epochs, start);
13780            published.set(true);
13781        });
13782        match result {
13783            Err(error) if published.get() => Err(MongrelError::CommitOutcomeUnknown {
13784                epoch: current.0,
13785                message: format!("history-retention publication was not durable: {error}"),
13786            }),
13787            result => result,
13788        }
13789    }
13790
13791    pub fn history_retention_epochs(&self) -> u64 {
13792        self.snapshots.history_config().0
13793    }
13794
13795    pub fn earliest_retained_epoch(&self) -> Epoch {
13796        let current = self.epoch.visible();
13797        self.snapshots.history_floor(current).unwrap_or(current)
13798    }
13799
13800    /// Pin a guaranteed historical epoch for the lifetime of the returned
13801    /// guard. Rejects future epochs and epochs outside the configured window.
13802    /// When a durable HLC receipt exists for `epoch`, the snapshot is
13803    /// HLC-authoritative (`at_hlc`); otherwise it falls back to epoch-only.
13804    pub fn snapshot_at_owned(&self, epoch: Epoch) -> Result<(Snapshot, OwnedSnapshotGuard)> {
13805        let current = self.epoch.visible();
13806        if epoch > current {
13807            return Err(MongrelError::InvalidArgument(format!(
13808                "epoch {} is in the future; current epoch is {}",
13809                epoch.0, current.0
13810            )));
13811        }
13812        let earliest = self.earliest_retained_epoch();
13813        if epoch < earliest {
13814            return Err(MongrelError::InvalidArgument(format!(
13815                "epoch {} is no longer retained; earliest available epoch is {}",
13816                epoch.0, earliest.0
13817            )));
13818        }
13819        let guard = self.snapshots.register_owned(epoch);
13820        let snap = match self.commit_ts_for_epoch(epoch) {
13821            Some(commit_ts) => Snapshot::at_hlc(epoch, commit_ts),
13822            None => Snapshot::at(epoch),
13823        };
13824        Ok((snap, guard))
13825    }
13826
13827    /// Names of all live tables.
13828    pub fn table_names(&self) -> Vec<String> {
13829        self.catalog
13830            .read()
13831            .tables
13832            .iter()
13833            .filter(|t| matches!(t.state, TableState::Live))
13834            .map(|t| t.name.clone())
13835            .collect()
13836    }
13837
13838    /// Best-effort flush-on-close (§4.4): force-flush every mounted table
13839    /// that has pending writes to a `.sr` sorted run, so WAL segments can be
13840    /// reaped on the next open. Call this as the last action before a
13841    /// short-lived process (CLI, one-shot script) exits. The daemon does not
13842    /// need this — its background auto-compactor handles run management.
13843    pub fn close(&self) -> Result<()> {
13844        for name in self.table_names() {
13845            if let Ok(handle) = self.table(&name) {
13846                if let Err(e) = handle.lock().close() {
13847                    eprintln!("[close] flush failed for {name}: {e}");
13848                }
13849            }
13850        }
13851        Ok(())
13852    }
13853
13854    /// Compact every mounted table: merge all sorted runs into one clean run
13855    /// so query cost stays flat (single-run fast path) instead of growing
13856    /// with run count. Tables with < 2 runs are skipped unless TTL has expired
13857    /// rows to reclaim. Each table
13858    /// is locked individually for its own compaction; snapshot retention is
13859    /// honored by `Table::compact`. Returns `(tables_compacted, tables_skipped)`.
13860    pub fn compact(&self) -> Result<(usize, usize)> {
13861        self.require(&crate::auth::Permission::Ddl)?;
13862        // S1A-004: admit the compaction pass as one core operation.
13863        let _operation = self.admit_operation()?;
13864        let mut compacted = 0;
13865        let mut skipped = 0;
13866        for name in self.table_names() {
13867            let Ok(handle) = self.table(&name) else {
13868                continue;
13869            };
13870            {
13871                let mut t = handle.lock();
13872                let before = t.run_count();
13873                if before < 2 && !t.should_compact() {
13874                    skipped += 1;
13875                    continue;
13876                }
13877                match t.compact() {
13878                    Ok(()) => {
13879                        let after = t.run_count();
13880                        compacted += 1;
13881                        eprintln!("[compact] {name}: {before} -> {after} runs");
13882                    }
13883                    Err(e) => {
13884                        eprintln!("[compact] {name}: compaction failed: {e}");
13885                        skipped += 1;
13886                    }
13887                }
13888            }
13889        }
13890        Ok((compacted, skipped))
13891    }
13892
13893    /// Compact a single table by name. Returns `Ok(true)` if it was
13894    /// compacted, `Ok(false)` if skipped (< 2 runs).
13895    pub fn compact_table(&self, name: &str) -> Result<bool> {
13896        self.require(&crate::auth::Permission::Ddl)?;
13897        let handle = self.table(name)?;
13898        let mut t = handle.lock();
13899        let before = t.run_count();
13900        if before < 2 {
13901            return Ok(false);
13902        }
13903        t.compact()?;
13904        Ok(t.run_count() < before)
13905    }
13906
13907    /// Look up a live table by name.
13908    pub fn table(&self, name: &str) -> Result<TableHandle> {
13909        self.ensure_owner_process()?;
13910        let cat = self.catalog.read();
13911        let entry = cat
13912            .live(name)
13913            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?;
13914        let id = entry.table_id;
13915        drop(cat);
13916        self.tables
13917            .read()
13918            .get(&id)
13919            .cloned()
13920            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not mounted")))
13921    }
13922
13923    /// Whether any mounted table has wall-clock TTL retention. SQL sessions
13924    /// use this to avoid epoch-keyed result caches that can outlive a cutoff.
13925    pub fn has_ttl_tables(&self) -> bool {
13926        self.tables
13927            .read()
13928            .values()
13929            .any(|table| table.lock().ttl().is_some())
13930    }
13931
13932    /// Resolve a live table id → mounted handle (used by the constraint
13933    /// validation pass and other id-qualified internal paths).
13934    pub(crate) fn table_by_id(&self, id: u64) -> Result<TableHandle> {
13935        self.tables
13936            .read()
13937            .get(&id)
13938            .cloned()
13939            .ok_or_else(|| MongrelError::NotFound(format!("table id {id} not mounted")))
13940    }
13941
13942    /// S1B-003: whether staging `cells` on `table_id` would ALLOCATE an
13943    /// auto-increment value — the auto-inc column absent or `Null`, mirroring
13944    /// `Table::fill_auto_inc`. The sequence barrier is held only for actual
13945    /// allocation; explicit values merely advance the counter (an
13946    /// order-insensitive `max` under the table lock) and must not serialize.
13947    pub(crate) fn table_auto_inc_would_allocate(
13948        &self,
13949        table_id: u64,
13950        cells: &[(u16, Value)],
13951    ) -> bool {
13952        let catalog = self.catalog.read();
13953        let Some(entry) = catalog
13954            .tables
13955            .iter()
13956            .find(|entry| entry.table_id == table_id)
13957        else {
13958            return false;
13959        };
13960        let Some(column) = entry.schema.auto_increment_column() else {
13961            return false;
13962        };
13963        matches!(
13964            cells.iter().find(|(id, _)| *id == column.id),
13965            None | Some((_, Value::Null))
13966        )
13967    }
13968
13969    /// Create a new table. The DDL is first logged to the shared WAL
13970    /// (`Op::Ddl(CreateTable)` + `TxnCommit`) and group-synced so it is durable
13971    /// BEFORE the in-memory catalog and table map are mutated; the catalog
13972    /// checkpoint is rewritten afterwards (spec §15, review fix #16). A reopen
13973    /// that sees a stale catalog still recovers the table by replaying the Ddl.
13974    pub fn create_table(&self, name: &str, schema: Schema) -> Result<u64> {
13975        if name.starts_with(CTAS_BUILD_TABLE_PREFIX) {
13976            return Err(MongrelError::InvalidArgument(format!(
13977                "table names beginning with {CTAS_BUILD_TABLE_PREFIX:?} are reserved"
13978            )));
13979        }
13980        self.create_table_with_state(name, schema, TableState::Live)
13981    }
13982
13983    /// Create a durable but non-queryable CTAS build table.
13984    #[doc(hidden)]
13985    pub fn create_building_table(
13986        &self,
13987        build_name: &str,
13988        intended_name: &str,
13989        query_id: &str,
13990        schema: Schema,
13991    ) -> Result<u64> {
13992        if !build_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
13993            || intended_name.is_empty()
13994            || intended_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
13995            || query_id.is_empty()
13996        {
13997            return Err(MongrelError::InvalidArgument(
13998                "invalid CTAS building-table identity".into(),
13999            ));
14000        }
14001        self.create_table_with_state(
14002            build_name,
14003            schema,
14004            TableState::Building {
14005                intended_name: intended_name.to_string(),
14006                query_id: query_id.to_string(),
14007                created_at_unix_nanos: current_unix_nanos(),
14008                replaces_table_id: None,
14009            },
14010        )
14011    }
14012
14013    /// Create a hidden schema-rebuild table while the intended target remains
14014    /// live. Publication later validates that the same target is still live.
14015    #[doc(hidden)]
14016    pub fn create_rebuilding_table(
14017        &self,
14018        build_name: &str,
14019        intended_name: &str,
14020        query_id: &str,
14021        schema: Schema,
14022    ) -> Result<u64> {
14023        if !build_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
14024            || intended_name.is_empty()
14025            || intended_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
14026            || query_id.is_empty()
14027        {
14028            return Err(MongrelError::InvalidArgument(
14029                "invalid rebuilding-table identity".into(),
14030            ));
14031        }
14032        let replaces_table_id = self
14033            .catalog
14034            .read()
14035            .live(intended_name)
14036            .ok_or_else(|| MongrelError::NotFound(format!("table {intended_name:?} not found")))?
14037            .table_id;
14038        self.create_table_with_state(
14039            build_name,
14040            schema,
14041            TableState::Building {
14042                intended_name: intended_name.to_string(),
14043                query_id: query_id.to_string(),
14044                created_at_unix_nanos: current_unix_nanos(),
14045                replaces_table_id: Some(replaces_table_id),
14046            },
14047        )
14048    }
14049
14050    fn create_table_with_state(
14051        &self,
14052        name: &str,
14053        schema: Schema,
14054        state: TableState,
14055    ) -> Result<u64> {
14056        use crate::wal::DdlOp;
14057        use std::sync::atomic::Ordering;
14058
14059        self.require(&crate::auth::Permission::Ddl)?;
14060        if self.poisoned.load(Ordering::Relaxed) {
14061            return Err(MongrelError::Other(
14062                "database poisoned by fsync error".into(),
14063            ));
14064        }
14065        // S1A-004: admit the DDL as one core operation.
14066        let _operation = self.admit_operation()?;
14067        let _schema_barrier = self.acquire_schema_barrier_exclusive()?;
14068        let _g = self.ddl_lock.lock();
14069        let _security_write = self.security_write()?;
14070        self.require(&crate::auth::Permission::Ddl)?;
14071        {
14072            let cat = self.catalog.read();
14073            match &state {
14074                TableState::Live => {
14075                    if cat.live(name).is_some() || cat.building_for(name).is_some() {
14076                        return Err(MongrelError::InvalidArgument(format!(
14077                            "table {name:?} already exists or is being built"
14078                        )));
14079                    }
14080                }
14081                TableState::Building {
14082                    intended_name,
14083                    replaces_table_id,
14084                    ..
14085                } => {
14086                    let target_matches = match replaces_table_id {
14087                        Some(table_id) => cat
14088                            .live(intended_name)
14089                            .is_some_and(|entry| entry.table_id == *table_id),
14090                        None => cat.live(intended_name).is_none(),
14091                    };
14092                    if !target_matches || cat.building_for(intended_name).is_some() {
14093                        return Err(MongrelError::InvalidArgument(format!(
14094                            "table {intended_name:?} changed or is already being built"
14095                        )));
14096                    }
14097                    if cat.building(name).is_some() {
14098                        return Err(MongrelError::InvalidArgument(format!(
14099                            "building table {name:?} already exists"
14100                        )));
14101                    }
14102                }
14103                TableState::Dropped { .. } => {
14104                    return Err(MongrelError::InvalidArgument(
14105                        "cannot create a dropped table".into(),
14106                    ));
14107                }
14108            }
14109        }
14110
14111        // Allocate id + epoch + txn id under the commit lock so the DDL commit
14112        // is serialized with data commits (in-order publish). Live creates
14113        // draw their table id from the routed catalog command below (S1F-001);
14114        // CTAS building-table creates keep the legacy direct allocation.
14115        let commit_lock = Arc::clone(&self.commit_lock);
14116        let _c = commit_lock.lock();
14117        let live_create = matches!(state, TableState::Live);
14118        let legacy_table_id = if live_create {
14119            None
14120        } else {
14121            let mut cat = self.catalog.write();
14122            let id = cat.next_table_id;
14123            cat.next_table_id = id
14124                .checked_add(1)
14125                .ok_or_else(|| MongrelError::InvalidArgument("table id space exhausted".into()))?;
14126            Some(id)
14127        };
14128        let epoch = self.epoch.bump_assigned();
14129        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
14130        let txn_id = self.alloc_txn_id()?;
14131
14132        let mut schema = schema;
14133        if let Some(table_id) = legacy_table_id {
14134            // Stamp the schema_id with the unique table_id so every table in
14135            // the database has a distinct schema_id (caller-provided values
14136            // are ignored to prevent collisions).
14137            schema.schema_id = table_id;
14138            // Defense in depth: reject an invalid schema BEFORE any durable
14139            // side-effect. `Table::create_in` re-validates, but by then the
14140            // DDL has already been appended to the shared WAL; a failing
14141            // create_in would leave a dangling entry that `recover_ddl_from_wal`
14142            // replays without re-validating, corrupting the catalog on reopen.
14143            // Validating here keeps the WAL free of schemas that can never be
14144            // opened.
14145            schema.validate_auto_increment()?;
14146            schema.validate_defaults()?;
14147            schema.validate_ai()?;
14148            for index in &schema.indexes {
14149                index.validate_options()?;
14150            }
14151            for constraint in &schema.constraints.checks {
14152                constraint.expr.validate()?;
14153            }
14154        }
14155
14156        let mut next_catalog = self.catalog.read().clone();
14157        let (table_id, schema) = match legacy_table_id {
14158            Some(table_id) => {
14159                next_catalog.tables.push(CatalogEntry {
14160                    table_id,
14161                    name: name.to_string(),
14162                    schema: schema.clone(),
14163                    state: state.clone(),
14164                    created_epoch: epoch.0,
14165                });
14166                (table_id, schema)
14167            }
14168            None => {
14169                // S1F-001: the mutation is a versioned catalog command —
14170                // schema validation (the same five validators), table-id
14171                // allocation, and schema_id stamping all resolve inside it.
14172                let command = crate::catalog_cmds::CatalogCommand::CreateTable {
14173                    name: name.to_string(),
14174                    schema,
14175                    created_epoch: epoch.0,
14176                };
14177                let delta = self.apply_catalog_command_to(&mut next_catalog, command)?;
14178                let crate::catalog_cmds::CatalogDelta::TableCreated { entry } = delta else {
14179                    return Err(MongrelError::Other(
14180                        "CreateTable resolved to an unexpected catalog delta".into(),
14181                    ));
14182                };
14183                (entry.table_id, entry.schema)
14184            }
14185        };
14186        next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
14187
14188        // Build the complete mounted table before its DDL can become durable.
14189        // Any failure removes the unpublished directory and abandons the epoch.
14190        let table_relative = Path::new(TABLES_DIR).join(table_id.to_string());
14191        let canonical_tdir = self.root.join(&table_relative);
14192        let table_root = Arc::new(
14193            self.durable_root
14194                .create_directory_all_pinned(&table_relative)?,
14195        );
14196        let tdir = table_root.io_path()?;
14197        let mut pending_table_dir = PendingTableDir::new(canonical_tdir);
14198        let ctx = SharedCtx {
14199            root_guard: Some(table_root),
14200            epoch: Arc::clone(&self.epoch),
14201            page_cache: Arc::clone(&self.page_cache),
14202            decoded_cache: Arc::clone(&self.decoded_cache),
14203            snapshots: Arc::clone(&self.snapshots),
14204            kek: self.kek.clone(),
14205            commit_lock: Arc::clone(&self.commit_lock),
14206            shared: Some(crate::engine::SharedWalCtx {
14207                wal: Arc::clone(&self.shared_wal),
14208                group: Arc::clone(&self.group),
14209                poisoned: Arc::clone(&self.poisoned),
14210                txn_ids: Arc::clone(&self.next_txn_id),
14211                change_wake: self.change_wake.clone(),
14212                lifecycle: Arc::clone(&self.lifecycle),
14213                hlc: Arc::clone(&self.hlc),
14214            }),
14215            table_name: Some(name.to_string()),
14216            auth: self.table_auth_checker(),
14217            read_only: self.read_only,
14218        };
14219        let table = Table::create_in(&tdir, schema.clone(), table_id, ctx)?;
14220
14221        // 1. Log the DDL + commit marker to the shared WAL, then make it durable
14222        //    via the group-commit coordinator (no fsync under the WAL lock — P3.2).
14223        let schema_json = DdlOp::encode_schema(&schema)?;
14224        let ddl = match &state {
14225            TableState::Live => DdlOp::CreateTable {
14226                table_id,
14227                name: name.to_string(),
14228                schema_json,
14229            },
14230            TableState::Building {
14231                intended_name,
14232                query_id,
14233                created_at_unix_nanos,
14234                replaces_table_id,
14235            } => match replaces_table_id {
14236                Some(replaces_table_id) => DdlOp::CreateRebuildingTable {
14237                    table_id,
14238                    build_name: name.to_string(),
14239                    intended_name: intended_name.clone(),
14240                    query_id: query_id.clone(),
14241                    created_at_unix_nanos: *created_at_unix_nanos,
14242                    replaces_table_id: *replaces_table_id,
14243                    schema_json,
14244                },
14245                None => DdlOp::CreateBuildingTable {
14246                    table_id,
14247                    build_name: name.to_string(),
14248                    intended_name: intended_name.clone(),
14249                    query_id: query_id.clone(),
14250                    created_at_unix_nanos: *created_at_unix_nanos,
14251                    schema_json,
14252                },
14253            },
14254            TableState::Dropped { .. } => {
14255                return Err(MongrelError::InvalidArgument(
14256                    "cannot create a table in dropped state".into(),
14257                ));
14258            }
14259        };
14260        let commit_seq = {
14261            let mut wal = self.shared_wal.lock();
14262            let append: Result<u64> = (|| {
14263                wal.append(txn_id, table_id, crate::wal::Op::Ddl(ddl))?;
14264                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
14265                wal.append_commit(txn_id, epoch, &[])
14266            })();
14267            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
14268        };
14269        let receipt = self.await_durable_commit(txn_id, commit_seq, epoch)?;
14270        pending_table_dir.disarm();
14271
14272        // Publish the mounted table and catalog in memory even if the catalog
14273        // checkpoint fails after the WAL commit.
14274        self.tables
14275            .write()
14276            .insert(table_id, TableHandle::new(table));
14277        let checkpoint = self.checkpoint_catalog_after_durable(next_catalog);
14278        self.finish_durable_publish(epoch, &mut _epoch_guard, &receipt, checkpoint)?;
14279        Ok(table_id)
14280    }
14281
14282    /// Logically drop a table, logging the DDL through the shared WAL first.
14283    pub fn drop_table(&self, name: &str) -> Result<()> {
14284        self.drop_table_with_epoch(name).map(|_| ())
14285    }
14286
14287    /// Logically drop a table and return the exact publication epoch.
14288    pub fn drop_table_with_epoch(&self, name: &str) -> Result<Epoch> {
14289        self.drop_table_with_state(name, false, None)
14290    }
14291
14292    pub fn drop_table_with_epoch_controlled<F>(
14293        &self,
14294        name: &str,
14295        mut before_commit: F,
14296    ) -> Result<Epoch>
14297    where
14298        F: FnMut() -> Result<()>,
14299    {
14300        self.drop_table_with_state(name, false, Some(&mut before_commit))
14301    }
14302
14303    /// Discard an unpublished CTAS build.
14304    #[doc(hidden)]
14305    pub fn discard_building_table(&self, name: &str) -> Result<()> {
14306        if !name.starts_with(CTAS_BUILD_TABLE_PREFIX) {
14307            return Err(MongrelError::InvalidArgument(
14308                "not a CTAS building table".into(),
14309            ));
14310        }
14311        self.drop_table_with_state(name, true, None).map(|_| ())
14312    }
14313
14314    fn drop_table_with_state(
14315        &self,
14316        name: &str,
14317        building: bool,
14318        before_commit: Option<&mut dyn FnMut() -> Result<()>>,
14319    ) -> Result<Epoch> {
14320        use crate::wal::DdlOp;
14321        use std::sync::atomic::Ordering;
14322
14323        self.require(&crate::auth::Permission::Ddl)?;
14324        if self.poisoned.load(Ordering::Relaxed) {
14325            return Err(MongrelError::Other(
14326                "database poisoned by fsync error".into(),
14327            ));
14328        }
14329        // S1A-004: admit the DDL as one core operation.
14330        let _operation = self.admit_operation()?;
14331        let _schema_barrier = self.acquire_schema_barrier_exclusive()?;
14332        let _g = self.ddl_lock.lock();
14333        let _security_write = self.security_write()?;
14334        self.require(&crate::auth::Permission::Ddl)?;
14335        let table_id = {
14336            let cat = self.catalog.read();
14337            if building {
14338                cat.building(name)
14339            } else {
14340                cat.live(name)
14341            }
14342            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?
14343            .table_id
14344        };
14345
14346        let commit_lock = Arc::clone(&self.commit_lock);
14347        let _c = commit_lock.lock();
14348        let epoch = self.epoch.bump_assigned();
14349        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
14350        let txn_id = self.alloc_txn_id()?;
14351        let mut next_catalog = self.catalog.read().clone();
14352        if building {
14353            // CTAS building-table discards stay on the legacy mutation: the
14354            // command model's `DropTable` resolves live tables only.
14355            let entry = next_catalog
14356                .tables
14357                .iter_mut()
14358                .find(|t| t.table_id == table_id)
14359                .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?;
14360            entry.state = TableState::Dropped { at_epoch: epoch.0 };
14361            next_catalog.triggers.retain(|trigger| {
14362                !matches!(
14363                    &trigger.trigger.target,
14364                    TriggerTarget::Table(target) if target == name
14365                )
14366            });
14367            next_catalog
14368                .materialized_views
14369                .retain(|definition| definition.name != name);
14370            next_catalog
14371                .security
14372                .rls_tables
14373                .retain(|table| table != name);
14374            next_catalog
14375                .security
14376                .policies
14377                .retain(|policy| policy.table != name);
14378            next_catalog
14379                .security
14380                .masks
14381                .retain(|mask| mask.table != name);
14382            for role in &mut next_catalog.roles {
14383                role.permissions
14384                    .retain(|permission| permission_table(permission) != Some(name));
14385            }
14386            advance_security_version(&mut next_catalog)?;
14387        } else {
14388            // S1F-001: a plain live-table drop is a versioned catalog command
14389            // (the delta mirrors the legacy mutation above, cascading
14390            // triggers, materialized views, and table-scoped security state).
14391            let command = crate::catalog_cmds::CatalogCommand::DropTable {
14392                name: name.to_string(),
14393                at_epoch: epoch.0,
14394            };
14395            self.apply_catalog_command_to(&mut next_catalog, command)?;
14396        }
14397        next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
14398        let commit_seq = {
14399            let mut wal = self.shared_wal.lock();
14400            if let Some(before_commit) = before_commit {
14401                before_commit()?;
14402            }
14403            let append: Result<u64> = (|| {
14404                wal.append(
14405                    txn_id,
14406                    table_id,
14407                    crate::wal::Op::Ddl(DdlOp::DropTable { table_id }),
14408                )?;
14409                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
14410                wal.append_commit(txn_id, epoch, &[])
14411            })();
14412            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
14413        };
14414        let receipt = self.await_durable_commit(txn_id, commit_seq, epoch)?;
14415
14416        let checkpoint = self.checkpoint_catalog_after_durable(next_catalog);
14417        self.tables.write().remove(&table_id);
14418        self.finish_durable_publish(epoch, &mut _epoch_guard, &receipt, checkpoint)?;
14419        Ok(epoch)
14420    }
14421
14422    /// Rename a live table. `name` must exist and `new_name` must not collide
14423    /// with any live table; both checks run under `ddl_lock` so they are atomic
14424    /// with the rename and with concurrent `create_table` existence checks (no
14425    /// TOCTOU window). A no-op rename (`name == new_name`) succeeds without
14426    /// side-effects. The rename is logged to the shared WAL as
14427    /// `DdlOp::RenameTable` and recovered on reopen; the `table_id`, schema,
14428    /// and on-disk layout are unchanged (the table is keyed by `table_id`, so
14429    /// the in-memory object does not move — only the catalog name changes).
14430    pub fn rename_table(&self, name: &str, new_name: &str) -> Result<()> {
14431        self.rename_table_with_epoch(name, new_name).map(|_| ())
14432    }
14433
14434    /// Rename a table and return its exact publication epoch.
14435    pub fn rename_table_with_epoch(&self, name: &str, new_name: &str) -> Result<Epoch> {
14436        self.rename_table_with_epoch_inner(name, new_name, None)
14437    }
14438
14439    pub fn rename_table_with_epoch_controlled<F>(
14440        &self,
14441        name: &str,
14442        new_name: &str,
14443        mut before_commit: F,
14444    ) -> Result<Epoch>
14445    where
14446        F: FnMut() -> Result<()>,
14447    {
14448        self.rename_table_with_epoch_inner(name, new_name, Some(&mut before_commit))
14449    }
14450
14451    fn rename_table_with_epoch_inner(
14452        &self,
14453        name: &str,
14454        new_name: &str,
14455        before_commit: Option<&mut dyn FnMut() -> Result<()>>,
14456    ) -> Result<Epoch> {
14457        if name.starts_with(CTAS_BUILD_TABLE_PREFIX)
14458            || new_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
14459        {
14460            return Err(MongrelError::InvalidArgument(
14461                "the CTAS building-table namespace is reserved".into(),
14462            ));
14463        }
14464        self.rename_table_with_state(name, new_name, false, None, before_commit)
14465    }
14466
14467    /// Atomically publish a hidden CTAS build under its intended live name.
14468    #[doc(hidden)]
14469    pub fn publish_building_table(&self, build_name: &str, new_name: &str) -> Result<Epoch> {
14470        self.publish_building_table_inner(build_name, new_name, None)
14471    }
14472
14473    #[doc(hidden)]
14474    pub fn publish_building_table_controlled<F>(
14475        &self,
14476        build_name: &str,
14477        new_name: &str,
14478        mut before_commit: F,
14479    ) -> Result<Epoch>
14480    where
14481        F: FnMut() -> Result<()>,
14482    {
14483        self.publish_building_table_inner(build_name, new_name, Some(&mut before_commit))
14484    }
14485
14486    fn publish_building_table_inner(
14487        &self,
14488        build_name: &str,
14489        new_name: &str,
14490        before_commit: Option<&mut dyn FnMut() -> Result<()>>,
14491    ) -> Result<Epoch> {
14492        if !build_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
14493            || new_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
14494        {
14495            return Err(MongrelError::InvalidArgument(
14496                "invalid CTAS publish identity".into(),
14497            ));
14498        }
14499        self.rename_table_with_state(build_name, new_name, true, None, before_commit)
14500    }
14501
14502    /// Atomically publish a hidden build and its materialized-view definition.
14503    #[doc(hidden)]
14504    pub fn publish_materialized_building_table(
14505        &self,
14506        build_name: &str,
14507        new_name: &str,
14508        definition: crate::catalog::MaterializedViewEntry,
14509    ) -> Result<Epoch> {
14510        self.publish_materialized_building_table_inner(build_name, new_name, definition, None)
14511    }
14512
14513    #[doc(hidden)]
14514    pub fn publish_materialized_building_table_controlled<F>(
14515        &self,
14516        build_name: &str,
14517        new_name: &str,
14518        definition: crate::catalog::MaterializedViewEntry,
14519        mut before_commit: F,
14520    ) -> Result<Epoch>
14521    where
14522        F: FnMut() -> Result<()>,
14523    {
14524        self.publish_materialized_building_table_inner(
14525            build_name,
14526            new_name,
14527            definition,
14528            Some(&mut before_commit),
14529        )
14530    }
14531
14532    fn publish_materialized_building_table_inner(
14533        &self,
14534        build_name: &str,
14535        new_name: &str,
14536        definition: crate::catalog::MaterializedViewEntry,
14537        before_commit: Option<&mut dyn FnMut() -> Result<()>>,
14538    ) -> Result<Epoch> {
14539        if definition.name != new_name || definition.query.trim().is_empty() {
14540            return Err(MongrelError::InvalidArgument(
14541                "invalid materialized-view publication".into(),
14542            ));
14543        }
14544        self.rename_table_with_state(build_name, new_name, true, Some(definition), before_commit)
14545    }
14546
14547    /// Atomically replace a still-live table with its completed hidden rebuild.
14548    #[doc(hidden)]
14549    pub fn publish_rebuilding_table(&self, build_name: &str, new_name: &str) -> Result<Epoch> {
14550        self.publish_rebuilding_table_inner(build_name, new_name, None, None)
14551    }
14552
14553    #[doc(hidden)]
14554    pub fn publish_rebuilding_table_controlled<F>(
14555        &self,
14556        build_name: &str,
14557        new_name: &str,
14558        mut before_commit: F,
14559    ) -> Result<Epoch>
14560    where
14561        F: FnMut() -> Result<()>,
14562    {
14563        self.publish_rebuilding_table_inner(build_name, new_name, None, Some(&mut before_commit))
14564    }
14565
14566    /// Atomically replace a live materialized-view table and its definition.
14567    #[doc(hidden)]
14568    pub fn publish_materialized_rebuilding_table_controlled<F>(
14569        &self,
14570        build_name: &str,
14571        new_name: &str,
14572        definition: crate::catalog::MaterializedViewEntry,
14573        mut before_commit: F,
14574    ) -> Result<Epoch>
14575    where
14576        F: FnMut() -> Result<()>,
14577    {
14578        self.publish_rebuilding_table_inner(
14579            build_name,
14580            new_name,
14581            Some(definition),
14582            Some(&mut before_commit),
14583        )
14584    }
14585
14586    fn publish_rebuilding_table_inner(
14587        &self,
14588        build_name: &str,
14589        new_name: &str,
14590        mut materialized_view: Option<crate::catalog::MaterializedViewEntry>,
14591        before_commit: Option<&mut dyn FnMut() -> Result<()>>,
14592    ) -> Result<Epoch> {
14593        use crate::wal::DdlOp;
14594
14595        if !build_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
14596            || new_name.is_empty()
14597            || new_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
14598        {
14599            return Err(MongrelError::InvalidArgument(
14600                "invalid rebuilding-table publish identity".into(),
14601            ));
14602        }
14603        if materialized_view.as_ref().is_some_and(|definition| {
14604            definition.name != new_name || definition.query.trim().is_empty()
14605        }) {
14606            return Err(MongrelError::InvalidArgument(
14607                "invalid materialized-view replacement".into(),
14608            ));
14609        }
14610        self.require(&crate::auth::Permission::Ddl)?;
14611        if self.poisoned.load(Ordering::Relaxed) {
14612            return Err(MongrelError::Other(
14613                "database poisoned by fsync error".into(),
14614            ));
14615        }
14616        // S1A-004: admit the DDL as one core operation.
14617        let _operation = self.admit_operation()?;
14618        let _schema_barrier = self.acquire_schema_barrier_exclusive()?;
14619        let _ddl = self.ddl_lock.lock();
14620        let _security_write = self.security_write()?;
14621        let (table_id, replaced_table_id) = {
14622            let catalog = self.catalog.read();
14623            let build = catalog.building(build_name).ok_or_else(|| {
14624                MongrelError::NotFound(format!("building table {build_name:?} not found"))
14625            })?;
14626            let replaced_table_id = match &build.state {
14627                TableState::Building {
14628                    intended_name,
14629                    replaces_table_id: Some(replaced_table_id),
14630                    ..
14631                } if intended_name == new_name => *replaced_table_id,
14632                _ => {
14633                    return Err(MongrelError::InvalidArgument(format!(
14634                        "building table {build_name:?} is not a replacement for {new_name:?}"
14635                    )))
14636                }
14637            };
14638            if catalog
14639                .live(new_name)
14640                .is_none_or(|entry| entry.table_id != replaced_table_id)
14641            {
14642                return Err(MongrelError::Conflict(format!(
14643                    "table {new_name:?} changed while its replacement was built"
14644                )));
14645            }
14646            (build.table_id, replaced_table_id)
14647        };
14648
14649        let _commit = self.commit_lock.lock();
14650        let epoch = self.epoch.assigned().next();
14651        let txn_id = self.alloc_txn_id()?;
14652        let mut next_catalog = self.catalog.read().clone();
14653        apply_rebuilding_publish(
14654            &mut next_catalog,
14655            table_id,
14656            replaced_table_id,
14657            new_name,
14658            epoch.0,
14659        )?;
14660        if let Some(definition) = materialized_view.as_mut() {
14661            definition.last_refresh_epoch = epoch.0;
14662        }
14663        let materialized_view_json = materialized_view
14664            .as_ref()
14665            .map(DdlOp::encode_materialized_view)
14666            .transpose()?;
14667        if let Some(definition) = materialized_view {
14668            if let Some(existing) = next_catalog
14669                .materialized_views
14670                .iter_mut()
14671                .find(|existing| existing.name == definition.name)
14672            {
14673                *existing = definition;
14674            } else {
14675                next_catalog.materialized_views.push(definition);
14676            }
14677        }
14678        next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
14679        if let Some(before_commit) = before_commit {
14680            before_commit()?;
14681        }
14682        let assigned_epoch = self.epoch.bump_assigned();
14683        let mut epoch_guard = EpochGuard::new(self.epoch.as_ref(), assigned_epoch);
14684        if assigned_epoch != epoch {
14685            return Err(MongrelError::Conflict(
14686                "commit epoch changed while sequencer lock was held".into(),
14687            ));
14688        }
14689        let commit_seq = {
14690            let mut wal = self.shared_wal.lock();
14691            let append: Result<u64> = (|| {
14692                wal.append(
14693                    txn_id,
14694                    table_id,
14695                    crate::wal::Op::Ddl(DdlOp::ReplaceBuildingTable {
14696                        table_id,
14697                        replaced_table_id,
14698                        new_name: new_name.to_string(),
14699                    }),
14700                )?;
14701                if let Some(definition_json) = materialized_view_json {
14702                    wal.append(
14703                        txn_id,
14704                        table_id,
14705                        crate::wal::Op::Ddl(DdlOp::SetMaterializedView {
14706                            name: new_name.to_string(),
14707                            definition_json,
14708                        }),
14709                    )?;
14710                }
14711                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
14712                wal.append_commit(txn_id, epoch, &[])
14713            })();
14714            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
14715        };
14716        let receipt = self.await_durable_commit(txn_id, commit_seq, epoch)?;
14717
14718        let checkpoint = self.checkpoint_catalog_after_durable(next_catalog);
14719        self.tables.write().remove(&replaced_table_id);
14720        if let Some(table) = self.tables.read().get(&table_id) {
14721            table.lock().set_catalog_name(new_name.to_string());
14722        }
14723        self.finish_durable_publish(epoch, &mut epoch_guard, &receipt, checkpoint)?;
14724        Ok(epoch)
14725    }
14726
14727    fn rename_table_with_state(
14728        &self,
14729        name: &str,
14730        new_name: &str,
14731        building: bool,
14732        mut materialized_view: Option<crate::catalog::MaterializedViewEntry>,
14733        before_commit: Option<&mut dyn FnMut() -> Result<()>>,
14734    ) -> Result<Epoch> {
14735        use crate::wal::DdlOp;
14736        use std::sync::atomic::Ordering;
14737
14738        self.require(&crate::auth::Permission::Ddl)?;
14739        if self.poisoned.load(Ordering::Relaxed) {
14740            return Err(MongrelError::Other(
14741                "database poisoned by fsync error".into(),
14742            ));
14743        }
14744
14745        // A no-op rename short-circuits before any locking, so it can never
14746        // trip the "target already exists" check (the source *is* that name).
14747        if name == new_name {
14748            return Ok(self.visible_epoch());
14749        }
14750        if new_name.is_empty() {
14751            return Err(MongrelError::InvalidArgument(
14752                "rename_table: new name must not be empty".into(),
14753            ));
14754        }
14755        // S1A-004: admit the DDL as one core operation.
14756        let _operation = self.admit_operation()?;
14757        let _schema_barrier = self.acquire_schema_barrier_exclusive()?;
14758        let _g = self.ddl_lock.lock();
14759        let _security_write = self.security_write()?;
14760        self.require(&crate::auth::Permission::Ddl)?;
14761        let table_id = {
14762            let cat = self.catalog.read();
14763            let src = if building {
14764                cat.building(name)
14765            } else {
14766                cat.live(name)
14767            }
14768            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?;
14769            if building
14770                && !matches!(
14771                    &src.state,
14772                    TableState::Building { intended_name, .. } if intended_name == new_name
14773                )
14774            {
14775                return Err(MongrelError::InvalidArgument(format!(
14776                    "building table {name:?} is not reserved for {new_name:?}"
14777                )));
14778            }
14779            // Target must be free. Checked under ddl_lock, which every other
14780            // DDL (create/rename/drop) also holds, so a concurrent operation
14781            // cannot claim `new_name` between this check and the catalog write.
14782            if cat.live(new_name).is_some() || (!building && cat.building_for(new_name).is_some()) {
14783                return Err(MongrelError::InvalidArgument(format!(
14784                    "rename_table: a table named {new_name:?} already exists"
14785                )));
14786            }
14787            src.table_id
14788        };
14789
14790        let commit_lock = Arc::clone(&self.commit_lock);
14791        let _c = commit_lock.lock();
14792        let epoch = self.epoch.bump_assigned();
14793        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
14794        let txn_id = self.alloc_txn_id()?;
14795        if let Some(definition) = materialized_view.as_mut() {
14796            definition.last_refresh_epoch = epoch.0;
14797        }
14798        let materialized_view_json = materialized_view
14799            .as_ref()
14800            .map(DdlOp::encode_materialized_view)
14801            .transpose()?;
14802        let mut next_catalog = self.catalog.read().clone();
14803        if building {
14804            // CTAS building-table publishes stay on the legacy mutation: the
14805            // command model's `RenameTable` resolves live tables only.
14806            let entry = next_catalog
14807                .tables
14808                .iter_mut()
14809                .find(|t| t.table_id == table_id)
14810                .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?;
14811            entry.name = new_name.to_string();
14812            entry.state = TableState::Live;
14813            for trigger in &mut next_catalog.triggers {
14814                if matches!(
14815                    &trigger.trigger.target,
14816                    TriggerTarget::Table(target) if target == name
14817                ) {
14818                    trigger.trigger = trigger.trigger.retarget_table(new_name, epoch.0)?;
14819                }
14820            }
14821            if let Some(definition) = next_catalog
14822                .materialized_views
14823                .iter_mut()
14824                .find(|definition| definition.name == name)
14825            {
14826                definition.name = new_name.to_string();
14827            }
14828            if let Some(definition) = materialized_view.take() {
14829                next_catalog.materialized_views.push(definition);
14830            }
14831            for table in &mut next_catalog.security.rls_tables {
14832                if table == name {
14833                    *table = new_name.to_string();
14834                }
14835            }
14836            for policy in &mut next_catalog.security.policies {
14837                if policy.table == name {
14838                    policy.table = new_name.to_string();
14839                }
14840            }
14841            for mask in &mut next_catalog.security.masks {
14842                if mask.table == name {
14843                    mask.table = new_name.to_string();
14844                }
14845            }
14846            for role in &mut next_catalog.roles {
14847                for permission in &mut role.permissions {
14848                    rename_permission_table(permission, name, new_name);
14849                }
14850            }
14851            advance_security_version(&mut next_catalog)?;
14852        } else {
14853            // S1F-001: a plain live-table rename is a versioned catalog
14854            // command (the delta mirrors the legacy mutation above, including
14855            // trigger retargets and table-scoped security state).
14856            let command = crate::catalog_cmds::CatalogCommand::RenameTable {
14857                name: name.to_string(),
14858                new_name: new_name.to_string(),
14859                at_epoch: epoch.0,
14860            };
14861            self.apply_catalog_command_to(&mut next_catalog, command)?;
14862        }
14863        next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
14864        let ddl = if building {
14865            DdlOp::PublishBuildingTable {
14866                table_id,
14867                new_name: new_name.to_string(),
14868            }
14869        } else {
14870            DdlOp::RenameTable {
14871                table_id,
14872                new_name: new_name.to_string(),
14873            }
14874        };
14875        let commit_seq = {
14876            let mut wal = self.shared_wal.lock();
14877            if let Some(before_commit) = before_commit {
14878                before_commit()?;
14879            }
14880            let append: Result<u64> = (|| {
14881                wal.append(txn_id, table_id, crate::wal::Op::Ddl(ddl))?;
14882                if let Some(definition_json) = materialized_view_json {
14883                    wal.append(
14884                        txn_id,
14885                        table_id,
14886                        crate::wal::Op::Ddl(DdlOp::SetMaterializedView {
14887                            name: new_name.to_string(),
14888                            definition_json,
14889                        }),
14890                    )?;
14891                }
14892                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
14893                wal.append_commit(txn_id, epoch, &[])
14894            })();
14895            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
14896        };
14897        let receipt = self.await_durable_commit(txn_id, commit_seq, epoch)?;
14898
14899        let checkpoint = self.checkpoint_catalog_after_durable(next_catalog);
14900        // The in-memory table object is keyed by table_id, not name, so it does
14901        // not move and live TableHandles remain valid.
14902        if let Some(table) = self.tables.read().get(&table_id) {
14903            table.lock().set_catalog_name(new_name.to_string());
14904        }
14905        self.finish_durable_publish(epoch, &mut _epoch_guard, &receipt, checkpoint)?;
14906        Ok(epoch)
14907    }
14908
14909    /// Add a column through the database catalog and shared WAL.
14910    ///
14911    /// This is the catalog-aware counterpart to [`Table::add_column`]. The
14912    /// mounted table schema, catalog image, and recovery record advance as one
14913    /// durable DDL commit.
14914    pub fn add_column(
14915        &self,
14916        table_name: &str,
14917        name: &str,
14918        ty: TypeId,
14919        flags: ColumnFlags,
14920        default_value: Option<crate::schema::DefaultExpr>,
14921    ) -> Result<ColumnDef> {
14922        self.add_column_with_id(table_name, name, ty, flags, default_value, None)
14923    }
14924
14925    /// Add a catalog-aware column with an optional caller-assigned stable id.
14926    pub fn add_column_with_id(
14927        &self,
14928        table_name: &str,
14929        name: &str,
14930        ty: TypeId,
14931        flags: ColumnFlags,
14932        default_value: Option<crate::schema::DefaultExpr>,
14933        requested_id: Option<u16>,
14934    ) -> Result<ColumnDef> {
14935        use crate::wal::DdlOp;
14936        use std::sync::atomic::Ordering;
14937
14938        self.require(&crate::auth::Permission::Ddl)?;
14939        if self.poisoned.load(Ordering::Relaxed) {
14940            return Err(MongrelError::Other(
14941                "database poisoned by fsync error".into(),
14942            ));
14943        }
14944        let _operation = self.admit_operation()?;
14945        let _schema_barrier = self.acquire_schema_barrier_exclusive()?;
14946        let _ddl = self.ddl_lock.lock();
14947        let _security_write = self.security_write()?;
14948        self.require(&crate::auth::Permission::Ddl)?;
14949        let table_id = {
14950            let catalog = self.catalog.read();
14951            catalog
14952                .live(table_name)
14953                .ok_or_else(|| MongrelError::NotFound(format!("table {table_name:?} not found")))?
14954                .table_id
14955        };
14956        let handle =
14957            self.tables.read().get(&table_id).cloned().ok_or_else(|| {
14958                MongrelError::NotFound(format!("table {table_name:?} not mounted"))
14959            })?;
14960        let durable_epoch = std::cell::Cell::new(None);
14961        let result: Result<ColumnDef> = (|| {
14962            let mut table = handle.lock();
14963            let (column, prepared_schema) =
14964                table.prepare_add_column(name, ty, flags, default_value, requested_id)?;
14965            let command = crate::catalog_cmds::CatalogCommand::AddColumn {
14966                table: table_name.to_string(),
14967                column: column.clone(),
14968            };
14969            crate::catalog_cmds::apply(&self.catalog.read(), &command)?;
14970
14971            let commit_lock = Arc::clone(&self.commit_lock);
14972            let _commit = commit_lock.lock();
14973            let epoch = self.epoch.bump_assigned();
14974            let mut epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
14975            let txn_id = self.alloc_txn_id()?;
14976            let column_json = DdlOp::encode_column(&column)?;
14977            let mut next_catalog = self.catalog.read().clone();
14978            let catalog_entry_index = next_catalog
14979                .tables
14980                .iter()
14981                .position(|entry| entry.table_id == table_id)
14982                .ok_or_else(|| MongrelError::NotFound(format!("table {table_name:?} not found")))?;
14983            self.apply_catalog_command_to(&mut next_catalog, command)?;
14984            next_catalog.tables[catalog_entry_index].schema = prepared_schema.clone();
14985            next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
14986            let commit_seq = {
14987                let mut wal = self.shared_wal.lock();
14988                let append: Result<u64> = (|| {
14989                    wal.append(
14990                        txn_id,
14991                        table_id,
14992                        crate::wal::Op::Ddl(DdlOp::AlterTable {
14993                            table_id,
14994                            column_json,
14995                        }),
14996                    )?;
14997                    append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
14998                    wal.append_commit(txn_id, epoch, &[])
14999                })();
15000                append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
15001            };
15002            let receipt = self.await_durable_commit(txn_id, commit_seq, epoch)?;
15003            durable_epoch.set(Some(epoch));
15004
15005            table.apply_altered_schema_prepared(prepared_schema);
15006            let schema = table.schema().clone();
15007            let table_checkpoint = table.checkpoint_altered_schema();
15008            drop(table);
15009            next_catalog.tables[catalog_entry_index].schema = schema;
15010            let catalog_result =
15011                catalog::write_atomic(&self.root, &next_catalog, self.meta_dek.as_ref());
15012            *self.catalog.write() = next_catalog;
15013            self.publish_committed(&receipt, epoch)?;
15014            epoch_guard.disarm();
15015            if let Err(error) = table_checkpoint.and(catalog_result) {
15016                self.poisoned.store(true, Ordering::Relaxed);
15017                self.lifecycle.poison();
15018                return Err(MongrelError::DurableCommit {
15019                    epoch: epoch.0,
15020                    message: error.to_string(),
15021                });
15022            }
15023            Ok(column)
15024        })();
15025        result.map_err(|error| match (durable_epoch.get(), error) {
15026            (_, error @ MongrelError::DurableCommit { .. }) => error,
15027            (Some(epoch), error) => MongrelError::DurableCommit {
15028                epoch: epoch.0,
15029                message: error.to_string(),
15030            },
15031            (None, error) => error,
15032        })
15033    }
15034
15035    pub fn alter_column(
15036        &self,
15037        table_name: &str,
15038        column_name: &str,
15039        change: AlterColumn,
15040    ) -> Result<ColumnDef> {
15041        self.alter_column_with_epoch(table_name, column_name, change)
15042            .map(|(column, _)| column)
15043    }
15044
15045    pub fn alter_column_with_epoch(
15046        &self,
15047        table_name: &str,
15048        column_name: &str,
15049        change: AlterColumn,
15050    ) -> Result<(ColumnDef, Option<Epoch>)> {
15051        self.alter_column_with_epoch_inner(table_name, column_name, change, None, None, None)
15052    }
15053
15054    /// Cooperatively prepare an ALTER and fence each durable commit separately.
15055    /// `after_commit(Some(epoch))` follows an exact durable outcome;
15056    /// `after_commit(None)` follows an uncertain WAL attempt. It is called once
15057    /// for every successful `before_commit` callback.
15058    pub fn alter_column_with_epoch_controlled<B, A>(
15059        &self,
15060        table_name: &str,
15061        column_name: &str,
15062        change: AlterColumn,
15063        control: &crate::ExecutionControl,
15064        mut before_commit: B,
15065        mut after_commit: A,
15066    ) -> Result<(ColumnDef, Option<Epoch>)>
15067    where
15068        B: FnMut() -> Result<()>,
15069        A: FnMut(Option<Epoch>) -> Result<()>,
15070    {
15071        self.alter_column_with_epoch_inner(
15072            table_name,
15073            column_name,
15074            change,
15075            Some(control),
15076            Some(&mut before_commit),
15077            Some(&mut after_commit),
15078        )
15079    }
15080
15081    #[allow(clippy::too_many_arguments)]
15082    fn alter_column_with_epoch_inner(
15083        &self,
15084        table_name: &str,
15085        column_name: &str,
15086        change: AlterColumn,
15087        control: Option<&crate::ExecutionControl>,
15088        mut before_commit: Option<&mut dyn FnMut() -> Result<()>>,
15089        mut after_commit: Option<&mut dyn FnMut(Option<Epoch>) -> Result<()>>,
15090    ) -> Result<(ColumnDef, Option<Epoch>)> {
15091        use crate::wal::DdlOp;
15092        use std::sync::atomic::Ordering;
15093
15094        self.require(&crate::auth::Permission::Ddl)?;
15095        commit_prepare_checkpoint(control, 0)?;
15096        if self.poisoned.load(Ordering::Relaxed) {
15097            return Err(MongrelError::Other(
15098                "database poisoned by fsync error".into(),
15099            ));
15100        }
15101        // S1A-004: admit the DDL as one core operation.
15102        let _operation = self.admit_operation()?;
15103        let _schema_barrier = self.acquire_schema_barrier_exclusive()?;
15104        let _g = self.ddl_lock.lock();
15105        let table_id = {
15106            let cat = self.catalog.read();
15107            cat.live(table_name)
15108                .ok_or_else(|| MongrelError::NotFound(format!("table {table_name:?} not found")))?
15109                .table_id
15110        };
15111        let handle =
15112            self.tables.read().get(&table_id).cloned().ok_or_else(|| {
15113                MongrelError::NotFound(format!("table {table_name:?} not mounted"))
15114            })?;
15115
15116        // Legitimate online-ALTER slice: when nullable -> NOT NULL has a
15117        // declared default, backfill existing NULL/absent cells as one durable
15118        // transaction before logging the metadata change. A crash between the
15119        // two commits leaves a harmless nullable-but-filled column; retry is
15120        // idempotent because only remaining NULLs are touched.
15121        let backfill = {
15122            let table = handle.lock();
15123            let old = table
15124                .schema()
15125                .column(column_name)
15126                .cloned()
15127                .ok_or_else(|| MongrelError::Schema(format!("unknown column {column_name}")))?;
15128            let next_flags = change.flags.unwrap_or(old.flags);
15129            if old.flags.contains(crate::schema::ColumnFlags::NULLABLE)
15130                && !next_flags.contains(crate::schema::ColumnFlags::NULLABLE)
15131                && old.default_value.is_some()
15132            {
15133                let snapshot = self.snapshot_for_epoch(self.epoch.visible());
15134                let mut updates = Vec::new();
15135                let rows = match control {
15136                    Some(control) => table.visible_rows_controlled(snapshot, control)?,
15137                    None => table.visible_rows(snapshot)?,
15138                };
15139                for (row_index, row) in rows.into_iter().enumerate() {
15140                    commit_prepare_checkpoint(control, row_index)?;
15141                    if row
15142                        .columns
15143                        .get(&old.id)
15144                        .is_some_and(|value| !matches!(value, Value::Null))
15145                    {
15146                        continue;
15147                    }
15148                    let mut cells: Vec<(u16, Value)> = row.columns.into_iter().collect();
15149                    table.apply_defaults(&mut cells)?;
15150                    updates.push((
15151                        table_id,
15152                        crate::txn::Staged::Update {
15153                            row_id: row.row_id,
15154                            new_row: cells,
15155                            changed_columns: vec![old.id],
15156                        },
15157                    ));
15158                }
15159                updates
15160            } else {
15161                Vec::new()
15162            }
15163        };
15164        let durable_epoch = std::cell::Cell::new(None);
15165        let backfill_epoch = if backfill.is_empty() {
15166            None
15167        } else {
15168            let (principal, catalog_bound) = self.transaction_principal_snapshot();
15169            let txn_id = self.alloc_txn_id()?;
15170            let mut entered_fence = false;
15171            let commit_result = match (control, before_commit.as_deref_mut()) {
15172                (Some(control), Some(before_commit)) => self
15173                    .commit_transaction_with_external_states_controlled(
15174                        txn_id,
15175                        self.epoch.visible(),
15176                        backfill,
15177                        Vec::new(),
15178                        Vec::new(),
15179                        principal.clone(),
15180                        catalog_bound,
15181                        None,
15182                        crate::txn::TxnCommitContext::internal(),
15183                        control,
15184                        &mut || {
15185                            before_commit()?;
15186                            entered_fence = true;
15187                            Ok(())
15188                        },
15189                    )
15190                    .map(|(epoch, _)| epoch),
15191                _ => self
15192                    .commit_transaction_with_external_states(
15193                        txn_id,
15194                        self.epoch.visible(),
15195                        backfill,
15196                        Vec::new(),
15197                        Vec::new(),
15198                        principal,
15199                        catalog_bound,
15200                        None,
15201                        crate::txn::TxnCommitContext::internal(),
15202                    )
15203                    .map(|(epoch, _)| epoch),
15204            };
15205            let commit_result = if entered_fence {
15206                finish_controlled_commit_attempt(commit_result, &mut after_commit)
15207            } else {
15208                commit_result
15209            };
15210            match &commit_result {
15211                Ok(epoch) => durable_epoch.set(Some(*epoch)),
15212                Err(MongrelError::DurableCommit { epoch, .. }) => {
15213                    durable_epoch.set(Some(Epoch(*epoch)));
15214                }
15215                Err(_) => {}
15216            }
15217            Some(commit_result?)
15218        };
15219        let result: Result<(ColumnDef, Option<Epoch>)> = (|| {
15220            let _security_write = self.security_write()?;
15221            self.require(&crate::auth::Permission::Ddl)?;
15222            if self
15223                .catalog
15224                .read()
15225                .live(table_name)
15226                .is_none_or(|entry| entry.table_id != table_id)
15227            {
15228                return Err(MongrelError::Conflict(format!(
15229                    "table {table_name:?} changed during ALTER"
15230                )));
15231            }
15232            let mut table = handle.lock();
15233            let (column, prepared_schema) = table.prepare_alter_column(column_name, &change)?;
15234            let renamed_column = (column.name != column_name).then(|| column.name.clone());
15235            let Some(prepared_schema) = prepared_schema else {
15236                return Ok((column, backfill_epoch));
15237            };
15238            // S1F-001: the schema mutation is a versioned catalog command. It
15239            // validates pure against the current catalog before an epoch is
15240            // consumed; the engine's post-apply schema (schema_id bump
15241            // included) is the resolved delta the wrapper publishes.
15242            let command = crate::catalog_cmds::CatalogCommand::AlterColumn {
15243                table: table_name.to_string(),
15244                column: column.clone(),
15245            };
15246            crate::catalog_cmds::apply(&self.catalog.read(), &command)?;
15247
15248            let commit_lock = Arc::clone(&self.commit_lock);
15249            let _c = commit_lock.lock();
15250            let epoch = self.epoch.bump_assigned();
15251            let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
15252            let txn_id = self.alloc_txn_id()?;
15253            let column_json = DdlOp::encode_column(&column)?;
15254            let mut next_catalog = self.catalog.read().clone();
15255            let catalog_entry_index = next_catalog
15256                .tables
15257                .iter()
15258                .position(|entry| entry.table_id == table_id)
15259                .ok_or_else(|| MongrelError::NotFound(format!("table {table_name:?} not found")))?;
15260            if let Some(new_column_name) = &renamed_column {
15261                for (trigger_index, trigger) in next_catalog.triggers.iter_mut().enumerate() {
15262                    commit_prepare_checkpoint(control, trigger_index)?;
15263                    if matches!(
15264                        &trigger.trigger.target,
15265                        TriggerTarget::Table(target) if target == table_name
15266                    ) {
15267                        trigger.trigger = trigger.trigger.renamed_update_column(
15268                            column_name,
15269                            new_column_name.clone(),
15270                            epoch.0,
15271                        )?;
15272                    }
15273                }
15274                for (role_index, role) in next_catalog.roles.iter_mut().enumerate() {
15275                    commit_prepare_checkpoint(control, role_index)?;
15276                    for (permission_index, permission) in role.permissions.iter_mut().enumerate() {
15277                        commit_prepare_checkpoint(control, permission_index)?;
15278                        rename_permission_column(
15279                            permission,
15280                            table_name,
15281                            column_name,
15282                            new_column_name,
15283                        );
15284                    }
15285                }
15286                advance_security_version(&mut next_catalog)?;
15287            }
15288            // Record the versioned command (validating again against the
15289            // candidate), then install the engine-resolved schema image:
15290            // identical to the command's delta when the mounted table and the
15291            // catalog are in sync, and byte-for-byte what the legacy inline
15292            // mutation published either way.
15293            self.apply_catalog_command_to(&mut next_catalog, command)?;
15294            next_catalog.tables[catalog_entry_index].schema = prepared_schema.clone();
15295            next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
15296            commit_prepare_checkpoint(control, 0)?;
15297            let mut entered_fence = false;
15298            if let Some(before_commit) = before_commit.as_deref_mut() {
15299                before_commit()?;
15300                entered_fence = true;
15301            }
15302            let commit_result: Result<Epoch> = (|| {
15303                let commit_seq = {
15304                    let mut wal = self.shared_wal.lock();
15305                    let append: Result<u64> = (|| {
15306                        wal.append(
15307                            txn_id,
15308                            table_id,
15309                            crate::wal::Op::Ddl(DdlOp::AlterTable {
15310                                table_id,
15311                                column_json,
15312                            }),
15313                        )?;
15314                        append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
15315                        wal.append_commit(txn_id, epoch, &[])
15316                    })();
15317                    append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
15318                };
15319                let receipt = self.await_durable_commit(txn_id, commit_seq, epoch)?;
15320                durable_epoch.set(Some(epoch));
15321
15322                table.apply_altered_schema_prepared(prepared_schema);
15323                let schema = table.schema().clone();
15324                let table_checkpoint = table.checkpoint_altered_schema();
15325                drop(table);
15326                next_catalog.tables[catalog_entry_index].schema = schema;
15327                next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
15328                let catalog_result =
15329                    catalog::write_atomic(&self.root, &next_catalog, self.meta_dek.as_ref());
15330                let security_version = next_catalog.security_version;
15331                *self.catalog.write() = next_catalog;
15332                if renamed_column.is_some() {
15333                    self.security_coordinator
15334                        .version
15335                        .store(security_version, Ordering::Release);
15336                }
15337                self.publish_committed(&receipt, epoch)?;
15338                _epoch_guard.disarm();
15339                if let Err(error) = table_checkpoint.and(catalog_result) {
15340                    self.poisoned.store(true, Ordering::Relaxed);
15341                    self.lifecycle.poison();
15342                    return Err(MongrelError::DurableCommit {
15343                        epoch: epoch.0,
15344                        message: error.to_string(),
15345                    });
15346                }
15347                Ok(epoch)
15348            })();
15349            let commit_result = if entered_fence {
15350                finish_controlled_commit_attempt(commit_result, &mut after_commit)
15351            } else {
15352                commit_result
15353            };
15354            let epoch = commit_result?;
15355            Ok((column, Some(epoch)))
15356        })();
15357        result.map_err(|error| match (durable_epoch.get(), error) {
15358            (_, error @ MongrelError::DurableCommit { .. }) => error,
15359            (Some(epoch), error) => MongrelError::DurableCommit {
15360                epoch: epoch.0,
15361                message: error.to_string(),
15362            },
15363            (None, error) => error,
15364        })
15365    }
15366
15367    /// Set a timestamp-column TTL policy and WAL-log it for crash recovery and
15368    /// replication. Duration is in nanoseconds.
15369    pub fn set_table_ttl(
15370        &self,
15371        table_name: &str,
15372        column_name: &str,
15373        duration_nanos: u64,
15374    ) -> Result<crate::manifest::TtlPolicy> {
15375        let policy = self.replace_table_ttl(table_name, Some((column_name, duration_nanos)))?;
15376        policy.ok_or_else(|| MongrelError::Other("set TTL produced no policy".into()))
15377    }
15378
15379    /// Set TTL metadata on a hidden build before it is published.
15380    #[doc(hidden)]
15381    pub fn set_building_table_ttl(
15382        &self,
15383        table_name: &str,
15384        column_name: &str,
15385        duration_nanos: u64,
15386    ) -> Result<crate::manifest::TtlPolicy> {
15387        let policy = self.replace_table_ttl_with_state(
15388            table_name,
15389            Some((column_name, duration_nanos)),
15390            true,
15391        )?;
15392        policy
15393            .ok_or_else(|| MongrelError::Other("set building-table TTL produced no policy".into()))
15394    }
15395
15396    pub fn clear_table_ttl(&self, table_name: &str) -> Result<()> {
15397        self.replace_table_ttl(table_name, None)?;
15398        Ok(())
15399    }
15400
15401    fn replace_table_ttl(
15402        &self,
15403        table_name: &str,
15404        requested: Option<(&str, u64)>,
15405    ) -> Result<Option<crate::manifest::TtlPolicy>> {
15406        self.replace_table_ttl_with_state(table_name, requested, false)
15407    }
15408
15409    fn replace_table_ttl_with_state(
15410        &self,
15411        table_name: &str,
15412        requested: Option<(&str, u64)>,
15413        building: bool,
15414    ) -> Result<Option<crate::manifest::TtlPolicy>> {
15415        use crate::wal::DdlOp;
15416        use std::sync::atomic::Ordering;
15417
15418        self.require(&crate::auth::Permission::Ddl)?;
15419        if self.poisoned.load(Ordering::Relaxed) {
15420            return Err(MongrelError::Other(
15421                "database poisoned by fsync error".into(),
15422            ));
15423        }
15424
15425        let _g = self.ddl_lock.lock();
15426        let _security_write = self.security_write()?;
15427        self.require(&crate::auth::Permission::Ddl)?;
15428        let table_id = {
15429            let cat = self.catalog.read();
15430            if building {
15431                cat.building(table_name)
15432            } else {
15433                cat.live(table_name)
15434            }
15435            .ok_or_else(|| MongrelError::NotFound(format!("table {table_name:?} not found")))?
15436            .table_id
15437        };
15438        let handle =
15439            self.tables.read().get(&table_id).cloned().ok_or_else(|| {
15440                MongrelError::NotFound(format!("table {table_name:?} not mounted"))
15441            })?;
15442        let mut table = handle.lock();
15443        let policy = match requested {
15444            Some((column, duration)) => Some(table.prepare_ttl_policy(column, duration)?),
15445            None => None,
15446        };
15447        if table.ttl() == policy {
15448            return Ok(policy);
15449        }
15450
15451        let commit_lock = Arc::clone(&self.commit_lock);
15452        let _c = commit_lock.lock();
15453        let epoch = self.epoch.bump_assigned();
15454        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
15455        let txn_id = self.alloc_txn_id()?;
15456        let policy_json = DdlOp::encode_ttl(policy)?;
15457        let mut next_catalog = self.catalog.read().clone();
15458        next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
15459        let commit_seq = {
15460            let mut wal = self.shared_wal.lock();
15461            let append: Result<u64> = (|| {
15462                wal.append(
15463                    txn_id,
15464                    table_id,
15465                    crate::wal::Op::Ddl(DdlOp::SetTtl {
15466                        table_id,
15467                        policy_json,
15468                    }),
15469                )?;
15470                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
15471                wal.append_commit(txn_id, epoch, &[])
15472            })();
15473            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
15474        };
15475        let receipt = self.await_durable_commit(txn_id, commit_seq, epoch)?;
15476
15477        let mut publish_error = table.apply_ttl_policy_at(policy, epoch).err();
15478        drop(table);
15479        if let Err(error) = self.checkpoint_catalog_after_durable(next_catalog) {
15480            publish_error.get_or_insert(error);
15481        }
15482        self.finish_durable_publish(
15483            epoch,
15484            &mut _epoch_guard,
15485            &receipt,
15486            publish_error.map_or(Ok(()), Err),
15487        )?;
15488        Ok(policy)
15489    }
15490
15491    /// Retention-gated garbage collection (spec §6.4, §7.4, §16). Deletes:
15492    /// - Dropped-table subdirs whose `at_epoch < min_active_snapshot`.
15493    /// - Stale `_txn/` dirs (aborted/crashed large-txn pending runs).
15494    ///
15495    /// Returns the number of items reclaimed.
15496    pub fn gc(&self) -> Result<usize> {
15497        let control = crate::ExecutionControl::new(None);
15498        self.gc_controlled(&control, || true)
15499    }
15500
15501    /// Discover reclaimable state cooperatively, then cross one publication
15502    /// boundary immediately before the first irreversible deletion.
15503    #[doc(hidden)]
15504    pub fn gc_controlled<F>(
15505        &self,
15506        control: &crate::ExecutionControl,
15507        before_publish: F,
15508    ) -> Result<usize>
15509    where
15510        F: FnOnce() -> bool,
15511    {
15512        self.gc_controlled_with_receipt(control, before_publish)
15513            .map(|(reclaimed, _)| reclaimed)
15514    }
15515
15516    /// Discover reclaimable state from one exact catalog/epoch snapshot, then
15517    /// return that snapshot if an irreversible deletion was attempted.
15518    #[doc(hidden)]
15519    pub fn gc_controlled_with_receipt<F>(
15520        &self,
15521        control: &crate::ExecutionControl,
15522        before_publish: F,
15523    ) -> Result<(usize, Option<MaintenanceReceipt>)>
15524    where
15525        F: FnOnce() -> bool,
15526    {
15527        enum Candidate {
15528            Directory(PathBuf),
15529            File(PathBuf),
15530        }
15531
15532        self.require(&crate::auth::Permission::Ddl)?;
15533        // S1A-004: admit the maintenance pass as one core operation.
15534        let _operation = self.admit_operation()?;
15535        let _ddl = self.ddl_lock.lock();
15536        self.require(&crate::auth::Permission::Ddl)?;
15537        control.checkpoint()?;
15538        let maintenance_epoch = self.epoch.visible();
15539        let min_active = self.snapshots.min_active(maintenance_epoch).0;
15540        let mut candidates = Vec::new();
15541
15542        // Reclaim dropped-table dirs where no pinned snapshot still needs them.
15543        let cat = self.catalog.read();
15544        for (entry_index, entry) in cat.tables.iter().enumerate() {
15545            if entry_index % 256 == 0 {
15546                control.checkpoint()?;
15547            }
15548            if let TableState::Dropped { at_epoch } = entry.state {
15549                if at_epoch <= min_active {
15550                    let tdir = self.root.join(TABLES_DIR).join(entry.table_id.to_string());
15551                    if tdir.exists() {
15552                        candidates.push(Candidate::Directory(tdir));
15553                    }
15554                }
15555            }
15556        }
15557        drop(cat);
15558
15559        // Sweep stale _txn/<id>/ dirs on remaining live tables — but NEVER an
15560        // in-flight spill's dir (deleting it would lose the pending run and fail
15561        // the commit, review fix #14). Each `_txn/` subdir is named by its txn id;
15562        // skip any id still registered in `active_spills`.
15563        let cat = self.catalog.read();
15564        for (entry_index, entry) in cat.tables.iter().enumerate() {
15565            if entry_index % 256 == 0 {
15566                control.checkpoint()?;
15567            }
15568            if !matches!(entry.state, TableState::Live) {
15569                continue;
15570            }
15571            let txn_dir = self
15572                .root
15573                .join(TABLES_DIR)
15574                .join(entry.table_id.to_string())
15575                .join("_txn");
15576            if !txn_dir.exists() {
15577                continue;
15578            }
15579            for (sub_index, sub) in std::fs::read_dir(&txn_dir)?.enumerate() {
15580                if sub_index % 256 == 0 {
15581                    control.checkpoint()?;
15582                }
15583                let sub = sub?;
15584                let name = sub.file_name();
15585                let Some(name) = name.to_str() else { continue };
15586                // A non-numeric entry can't belong to a live txn — sweep it.
15587                let is_active = name
15588                    .parse::<u64>()
15589                    .map(|id| self.active_spills.is_active(id))
15590                    .unwrap_or(false);
15591                if is_active {
15592                    continue;
15593                }
15594                candidates.push(Candidate::Directory(sub.path()));
15595            }
15596        }
15597        drop(cat);
15598
15599        let external_names = {
15600            let cat = self.catalog.read();
15601            cat.external_tables
15602                .iter()
15603                .map(|entry| entry.name.clone())
15604                .collect::<std::collections::HashSet<_>>()
15605        };
15606        let vtab_dir = self.root.join(VTAB_DIR);
15607        if vtab_dir.exists() {
15608            for (entry_index, entry) in std::fs::read_dir(&vtab_dir)?.enumerate() {
15609                if entry_index % 256 == 0 {
15610                    control.checkpoint()?;
15611                }
15612                let entry = entry?;
15613                let name = entry.file_name();
15614                let Some(name) = name.to_str() else { continue };
15615                if external_names.contains(name) {
15616                    continue;
15617                }
15618                let path = entry.path();
15619                if path.is_dir() {
15620                    candidates.push(Candidate::Directory(path));
15621                } else {
15622                    candidates.push(Candidate::File(path));
15623                }
15624            }
15625        }
15626
15627        // Reap compaction-superseded runs whose retire epoch no pinned snapshot
15628        // can still need (spec §6.4). Each table deletes its own retired files
15629        // gated on `min_active` and persists its manifest.
15630        let tables = self
15631            .tables
15632            .read()
15633            .iter()
15634            .map(|(table_id, handle)| (*table_id, handle.clone()))
15635            .collect::<Vec<_>>();
15636        let mut retiring = Vec::new();
15637        for (table_index, (table_id, handle)) in tables.iter().enumerate() {
15638            if table_index % 256 == 0 {
15639                control.checkpoint()?;
15640            }
15641            let backup_pinned: HashSet<u128> = self
15642                .backup_pins
15643                .lock()
15644                .keys()
15645                .filter_map(|(pinned_table, run_id)| {
15646                    (*pinned_table == *table_id).then_some(*run_id)
15647                })
15648                .collect();
15649            if handle
15650                .lock()
15651                .has_reapable_retiring(Epoch(min_active), &backup_pinned)
15652            {
15653                retiring.push((handle.clone(), backup_pinned));
15654            }
15655        }
15656
15657        // WAL-segment GC (spec §6.4/§16). `SharedWal::open` mints a fresh active
15658        // segment on every reopen without truncating the prior ones, so rotated
15659        // segments accumulate. Once every live table's committed data is durable
15660        // in runs (no in-memory rows) and no in-flight spill is open, all rotated
15661        // (non-active) segments are redundant for recovery and safe to delete —
15662        // an in-flight txn only ever appends to the active segment, which is
15663        // never deleted.
15664        let all_durable = self.active_spills.is_idle()
15665            && tables.iter().all(|(_, handle)| {
15666                let g = handle.lock();
15667                g.memtable_len() == 0 && g.mutable_run_len() == 0
15668            });
15669        let retain = self
15670            .replication_wal_retention_segments
15671            .load(std::sync::atomic::Ordering::Relaxed);
15672        let reap_wal = all_durable
15673            && self
15674                .shared_wal
15675                .lock()
15676                .has_gc_segments_retain_recent(retain)?;
15677
15678        if candidates.is_empty() && retiring.is_empty() && !reap_wal {
15679            return Ok((0, None));
15680        }
15681        control.checkpoint()?;
15682        if !before_publish() {
15683            return Err(MongrelError::Cancelled);
15684        }
15685
15686        let mut reclaimed = 0;
15687        for candidate in candidates {
15688            match candidate {
15689                Candidate::Directory(path) => std::fs::remove_dir_all(path)?,
15690                Candidate::File(path) => std::fs::remove_file(path)?,
15691            }
15692            reclaimed += 1;
15693        }
15694        for (handle, backup_pinned) in retiring {
15695            reclaimed += handle
15696                .lock()
15697                .reap_retiring(Epoch(min_active), &backup_pinned)?;
15698        }
15699        if reap_wal {
15700            reclaimed += self
15701                .shared_wal
15702                .lock()
15703                .gc_segments_retain_recent(u64::MAX, retain)?;
15704        }
15705
15706        Ok((
15707            reclaimed,
15708            Some(MaintenanceReceipt {
15709                epoch: maintenance_epoch,
15710            }),
15711        ))
15712    }
15713
15714    /// Produce a deterministic-stable byte image of the database directory.
15715    ///
15716    /// After `checkpoint()`:
15717    ///   - All pending writes are flushed to sorted runs (no memtable data).
15718    ///   - Each table is compacted to a single sorted run (no run fragmentation).
15719    ///   - All non-active WAL segments are deleted (data is durable in runs).
15720    ///   - The active WAL segment is rotated to a fresh empty segment.
15721    ///   - Dropped-table directories are removed.
15722    ///   - All manifests + catalog are persisted.
15723    ///
15724    /// The resulting directory is byte-stable: `git add` captures a snapshot
15725    /// that `git checkout` restores deterministically. No stale WAL tail bytes,
15726    /// no unbounded segment growth, no mutable-run spill files.
15727    ///
15728    /// This is the engine primitive behind `mongreldb snapshot <dir>` (CLI).
15729    /// It does NOT clear the exclusive lock — the caller still owns the
15730    /// database handle.
15731    pub fn checkpoint(&self) -> Result<()> {
15732        self.checkpoint_controlled(|| Ok(()))
15733    }
15734
15735    /// Strict checkpoint with a deterministic test hook after every table is
15736    /// flushed/compacted but before WAL replacement.
15737    #[doc(hidden)]
15738    pub fn checkpoint_controlled<F>(&self, before_wal_reset: F) -> Result<()>
15739    where
15740        F: FnOnce() -> Result<()>,
15741    {
15742        self.require(&crate::auth::Permission::Ddl)?;
15743        // S1A-004: admit the checkpoint as one core operation.
15744        let _operation = self.admit_operation()?;
15745        // Block cross-table commits and DDL for the full operation. Locking all
15746        // mounted handles also excludes direct `Table` commits, which do not
15747        // enter the database replication barrier.
15748        let _replication = self.replication_barrier.write();
15749        let _ddl = self.ddl_lock.lock();
15750        let _security = self.security_coordinator.gate.read();
15751        self.require(&crate::auth::Permission::Ddl)?;
15752
15753        let mut handles = self
15754            .tables
15755            .read()
15756            .iter()
15757            .map(|(table_id, handle)| (*table_id, handle.clone()))
15758            .collect::<Vec<_>>();
15759        handles.sort_by_key(|(table_id, _)| *table_id);
15760        let mut tables = handles
15761            .iter()
15762            .map(|(table_id, handle)| (*table_id, handle.lock()))
15763            .collect::<Vec<_>>();
15764
15765        // Strict flush. Any error leaves the old WAL recovery source intact.
15766        for (_, table) in &mut tables {
15767            if table.has_pending_writes() || table.memtable_len() > 0 || table.mutable_run_len() > 0
15768            {
15769                table.force_flush()?;
15770            }
15771        }
15772
15773        // Strict compaction. Checkpoint never reports a stable image after a
15774        // skipped failure.
15775        for (_, table) in &mut tables {
15776            if table.run_count() >= 2 || table.should_compact() {
15777                table.compact()?;
15778            }
15779        }
15780
15781        before_wal_reset()?;
15782
15783        // Reap table-local retired runs while every table remains quiesced.
15784        let maintenance_epoch = self.epoch.visible();
15785        let min_active = self.snapshots.min_active(maintenance_epoch);
15786        for (table_id, table) in &mut tables {
15787            let backup_pinned: HashSet<u128> = self
15788                .backup_pins
15789                .lock()
15790                .keys()
15791                .filter_map(|(pinned_table, run_id)| {
15792                    (*pinned_table == *table_id).then_some(*run_id)
15793                })
15794                .collect();
15795            table.reap_retiring(min_active, &backup_pinned)?;
15796        }
15797
15798        // Publish a fresh synced active WAL, then durably reap every older
15799        // segment. This point is reached only after every strict flush succeeds.
15800        self.shared_wal.lock().reset_after_checkpoint()?;
15801
15802        // Remove catalog-unreachable directories and stale transaction state.
15803        let catalog_snapshot = self.catalog.read().clone();
15804        for entry in &catalog_snapshot.tables {
15805            if matches!(entry.state, TableState::Dropped { at_epoch } if at_epoch <= min_active.0) {
15806                crate::durable_file::remove_directory_all(
15807                    &self.root.join(TABLES_DIR).join(entry.table_id.to_string()),
15808                )?;
15809            }
15810            if !matches!(entry.state, TableState::Live) {
15811                continue;
15812            }
15813            let transaction_dir = self
15814                .root
15815                .join(TABLES_DIR)
15816                .join(entry.table_id.to_string())
15817                .join("_txn");
15818            if transaction_dir.is_dir() {
15819                for child in std::fs::read_dir(&transaction_dir)? {
15820                    let child = child?;
15821                    let active = child
15822                        .file_name()
15823                        .to_str()
15824                        .and_then(|name| name.parse::<u64>().ok())
15825                        .is_some_and(|txn_id| self.active_spills.is_active(txn_id));
15826                    if !active {
15827                        crate::durable_file::remove_directory_all(&child.path())?;
15828                    }
15829                }
15830            }
15831        }
15832        let external_names = catalog_snapshot
15833            .external_tables
15834            .iter()
15835            .map(|entry| entry.name.as_str())
15836            .collect::<HashSet<_>>();
15837        let external_root = self.root.join(VTAB_DIR);
15838        if external_root.is_dir() {
15839            for entry in std::fs::read_dir(&external_root)? {
15840                let entry = entry?;
15841                let name = entry.file_name();
15842                if name
15843                    .to_str()
15844                    .is_some_and(|name| external_names.contains(name))
15845                {
15846                    continue;
15847                }
15848                if entry.file_type()?.is_dir() {
15849                    crate::durable_file::remove_directory_all(&entry.path())?;
15850                } else {
15851                    std::fs::remove_file(entry.path())?;
15852                    crate::durable_file::sync_directory(&external_root)?;
15853                }
15854            }
15855        }
15856
15857        // Final authoritative metadata checkpoint while all writers remain
15858        // excluded.
15859        catalog::write_atomic(&self.root, &catalog_snapshot, self.meta_dek.as_ref())?;
15860        let visible = self.epoch.visible();
15861        for (_, table) in &tables {
15862            table.persist_manifest(visible)?;
15863        }
15864
15865        Ok(())
15866    }
15867    fn alloc_txn_id(&self) -> Result<u64> {
15868        self.ensure_owner_process()?;
15869        crate::txn::allocate_txn_id(&self.next_txn_id)
15870    }
15871
15872    /// Allocate a lock-manager transaction id for SQL `SELECT ... FOR UPDATE`
15873    /// (or other multi-statement lock holds). Released via
15874    /// [`Self::release_txn_locks`].
15875    pub fn allocate_lock_txn_id(&self) -> Result<u64> {
15876        self.alloc_txn_id()
15877    }
15878
15879    /// Set the per-table spill threshold (bytes). When a transaction's staged
15880    /// bytes for a single table exceed this, the rows are written as a
15881    /// uniform-epoch pending run instead of streamed Put records (spec §8.5).
15882    pub fn set_spill_threshold(&self, bytes: u64) {
15883        self.spill_threshold
15884            .store(bytes, std::sync::atomic::Ordering::Relaxed);
15885    }
15886
15887    /// Test-only: install a hook invoked after a transaction writes its spill
15888    /// runs but before the sequencer, so a test can race `gc()` against an
15889    /// in-flight spill. Not part of the stable API.
15890    #[doc(hidden)]
15891    pub fn __set_spill_hook(&self, f: impl Fn() + Send + Sync + 'static) {
15892        *self.spill_hook.lock() = Some(Box::new(f));
15893    }
15894
15895    /// Test-only: install a hook invoked while a spilled commit holds the
15896    /// security read gate and before it appends to the WAL.
15897    #[doc(hidden)]
15898    pub fn __set_security_commit_hook(&self, f: impl Fn() + Send + Sync + 'static) {
15899        *self.security_commit_hook.lock() = Some(Box::new(f));
15900    }
15901
15902    /// Test-only: install a hook after transaction preparation and before the
15903    /// commit sequencer validates catalog generations.
15904    #[doc(hidden)]
15905    pub fn __set_catalog_commit_hook(&self, f: impl Fn() + Send + Sync + 'static) {
15906        *self.catalog_commit_hook.lock() = Some(Box::new(f));
15907    }
15908
15909    /// Test-only: pause an online backup after its consistent boundary is
15910    /// captured but before the pinned immutable runs are copied.
15911    #[doc(hidden)]
15912    pub fn __set_backup_hook(&self, f: impl Fn() + Send + Sync + 'static) {
15913        *self.backup_hook.lock() = Some(Box::new(f));
15914    }
15915
15916    /// Test-only: invoked after each successful FK parent-protection lock
15917    /// acquisition during constraint validation, so tests can rendezvous two
15918    /// committing transactions into a deterministic wait-for cycle.
15919    #[doc(hidden)]
15920    pub fn __set_fk_lock_hook(&self, f: impl Fn() + Send + Sync + 'static) {
15921        *self.fk_lock_hook.lock() = Some(Arc::new(f));
15922    }
15923
15924    /// Test-only: pause WAL extraction before its final principal recheck.
15925    #[doc(hidden)]
15926    pub fn __set_replication_hook(&self, f: impl Fn() + Send + Sync + 'static) {
15927        *self.replication_hook.lock() = Some(Box::new(f));
15928    }
15929
15930    /// Number of WAL fsyncs issued so far (test/diagnostic). With group commit
15931    /// this stays well below the number of committed transactions when commits
15932    /// are concurrent (one leader fsync covers a whole batch — spec §9.3).
15933    #[doc(hidden)]
15934    pub fn __wal_group_sync_count(&self) -> u64 {
15935        self.shared_wal.lock().group_sync_count()
15936    }
15937
15938    /// Force the poisoned state (test-only) to verify the §9.3e fail-fast
15939    /// contract that an fsync error would trigger in production.
15940    #[doc(hidden)]
15941    pub fn __poison(&self) {
15942        self.poisoned
15943            .store(true, std::sync::atomic::Ordering::Relaxed);
15944    }
15945
15946    /// Verify multi-table integrity (spec §16). For every live table this:
15947    /// authenticates the manifest; opens each `RunRef`'s file through
15948    /// [`RunReader`](crate::sorted_run::RunReader), which verifies the run footer
15949    /// checksum and — for encrypted DBs — the keyed run-metadata MAC; checks each
15950    /// run's physical row count against its `RunRef`; flags `RunRef`s whose file
15951    /// is missing (dangling) and `.sr` files on disk that no `RunRef` references
15952    /// (orphan); and verifies `flushed_epoch <= current_epoch`. Returns the list
15953    /// of issues found (empty = healthy). Orphans are `warning`-severity; all
15954    /// other findings are `error`-severity (so [`Self::doctor`] quarantines them).
15955    ///
15956    /// Cost: O(total run bytes) — the footer checksum is verified over each run's
15957    /// full body, so this is an integrity tool, not a hot path.
15958    pub fn check(&self) -> Vec<CheckIssue> {
15959        match self.check_inner(None) {
15960            Ok(issues) => issues,
15961            Err(error) => vec![CheckIssue {
15962                table_id: WAL_TABLE_ID,
15963                table_name: "shared WAL".into(),
15964                severity: "error".into(),
15965                description: error.to_string(),
15966            }],
15967        }
15968    }
15969
15970    /// Integrity check with cooperative cancellation between tables and runs.
15971    #[doc(hidden)]
15972    pub fn check_controlled(&self, control: &crate::ExecutionControl) -> Result<Vec<CheckIssue>> {
15973        self.check_inner(Some(control))
15974    }
15975
15976    fn check_inner(&self, control: Option<&crate::ExecutionControl>) -> Result<Vec<CheckIssue>> {
15977        let mut issues = Vec::new();
15978        let io_root = self.durable_root.io_path()?;
15979        let cat = self.catalog.read();
15980        let manifest_meta_dek = crate::encryption::meta_dek_for(self.kek.as_deref());
15981        for (table_index, entry) in cat.tables.iter().enumerate() {
15982            if table_index % 256 == 0 {
15983                if let Some(control) = control {
15984                    control.checkpoint()?;
15985                }
15986            }
15987            if !matches!(entry.state, TableState::Live) {
15988                continue;
15989            }
15990            let tdir = io_root.join(TABLES_DIR).join(entry.table_id.to_string());
15991            let mut err = |sev: &str, desc: String| {
15992                issues.push(CheckIssue {
15993                    table_id: entry.table_id,
15994                    table_name: entry.name.clone(),
15995                    severity: sev.into(),
15996                    description: desc,
15997                });
15998            };
15999            let m = match crate::manifest::read(&tdir, manifest_meta_dek.as_ref()) {
16000                Ok(m) => m,
16001                Err(e) => {
16002                    err("error", format!("manifest read failed: {e}"));
16003                    continue;
16004                }
16005            };
16006            if m.flushed_epoch > m.current_epoch {
16007                err(
16008                    "error",
16009                    format!(
16010                        "flushed_epoch {} exceeds current_epoch {} (impossible)",
16011                        m.flushed_epoch, m.current_epoch
16012                    ),
16013                );
16014            }
16015
16016            let runs_dir = tdir.join(crate::engine::RUNS_DIR);
16017            let mut referenced: std::collections::HashSet<u128> = std::collections::HashSet::new();
16018            for (run_index, rr) in m.runs.iter().enumerate() {
16019                if run_index % 256 == 0 {
16020                    if let Some(control) = control {
16021                        control.checkpoint()?;
16022                    }
16023                }
16024                referenced.insert(rr.run_id);
16025                let run_path = runs_dir.join(format!("r-{}.sr", rr.run_id));
16026                if !run_path.exists() {
16027                    err("error", format!("missing run file: r-{}.sr", rr.run_id));
16028                    continue;
16029                }
16030                match crate::sorted_run::RunReader::open(
16031                    &run_path,
16032                    entry.schema.clone(),
16033                    self.kek.clone(),
16034                ) {
16035                    Ok(reader) => {
16036                        if reader.row_count() as u64 != rr.row_count {
16037                            err(
16038                                "error",
16039                                format!(
16040                                    "run r-{} row count mismatch: manifest {} vs run {}",
16041                                    rr.run_id,
16042                                    rr.row_count,
16043                                    reader.row_count()
16044                                ),
16045                            );
16046                        }
16047                    }
16048                    Err(e) => {
16049                        err(
16050                            "error",
16051                            format!("run r-{} integrity check failed: {e}", rr.run_id),
16052                        );
16053                    }
16054                }
16055            }
16056
16057            // Compaction-superseded runs awaiting retention-gated deletion are
16058            // tracked in `retiring`; their files are expected on disk, so they
16059            // are not orphans.
16060            for r in &m.retiring {
16061                referenced.insert(r.run_id);
16062            }
16063
16064            // Orphan `.sr` files present on disk but absent from the manifest.
16065            if let Ok(rd) = std::fs::read_dir(&runs_dir) {
16066                for (entry_index, ent) in rd.flatten().enumerate() {
16067                    if entry_index % 256 == 0 {
16068                        if let Some(control) = control {
16069                            control.checkpoint()?;
16070                        }
16071                    }
16072                    let p = ent.path();
16073                    if p.extension().and_then(|s| s.to_str()) != Some("sr") {
16074                        continue;
16075                    }
16076                    let run_id = p
16077                        .file_stem()
16078                        .and_then(|s| s.to_str())
16079                        .and_then(|s| s.strip_prefix("r-"))
16080                        .and_then(|s| s.parse::<u128>().ok());
16081                    if let Some(id) = run_id {
16082                        if !referenced.contains(&id) {
16083                            err(
16084                                "warning",
16085                                format!("orphan run file r-{id}.sr not referenced by the manifest"),
16086                            );
16087                        }
16088                    }
16089                }
16090            }
16091        }
16092
16093        let external_names = cat
16094            .external_tables
16095            .iter()
16096            .map(|entry| entry.name.clone())
16097            .collect::<std::collections::HashSet<_>>();
16098        let vtab_dir = io_root.join(VTAB_DIR);
16099        if let Ok(entries) = std::fs::read_dir(&vtab_dir) {
16100            for (entry_index, entry) in entries.flatten().enumerate() {
16101                if entry_index % 256 == 0 {
16102                    if let Some(control) = control {
16103                        control.checkpoint()?;
16104                    }
16105                }
16106                let name = entry.file_name();
16107                let Some(name) = name.to_str() else { continue };
16108                if !external_names.contains(name) {
16109                    issues.push(CheckIssue {
16110                        table_id: EXTERNAL_TABLE_ID,
16111                        table_name: name.to_string(),
16112                        severity: "warning".into(),
16113                        description: format!(
16114                            "orphan external table state entry {:?} not referenced by the catalog",
16115                            entry.path()
16116                        ),
16117                    });
16118                }
16119            }
16120        }
16121
16122        // WAL retention / integrity invariant (spec §16): every on-disk WAL
16123        // segment must open (header magic + version, and the frame cipher must
16124        // be derivable for an encrypted WAL). A segment that won't open is
16125        // corrupt or truncated and would break crash recovery. `table_id` is
16126        // the reserved `WAL_TABLE_ID` sentinel (u64::MAX) so [`Self::doctor`]
16127        // never confuses a WAL issue with a real table.
16128        if let Some(control) = control {
16129            control.checkpoint()?;
16130        }
16131        for (seg, msg) in self.shared_wal.lock().verify_segments() {
16132            issues.push(CheckIssue {
16133                table_id: WAL_TABLE_ID,
16134                table_name: "<wal>".into(),
16135                severity: "error".into(),
16136                description: format!("WAL segment seg-{seg:06}.wal failed integrity check: {msg}"),
16137            });
16138        }
16139        Ok(issues)
16140    }
16141
16142    /// Quarantine unreadable tables (spec §16). Moves corrupt table dirs to
16143    /// `_quarantine/<table_id>/`, marks them dropped in the catalog, and
16144    /// unmounts them from the live table map so the DB still opens.
16145    pub fn doctor(&self) -> Result<Vec<u64>> {
16146        let control = crate::ExecutionControl::new(None);
16147        self.doctor_controlled(&control, || true)
16148    }
16149
16150    /// Check cancellably, then fence immediately before the first quarantine
16151    /// mutation. Returning `false` from `before_publish` leaves the database
16152    /// untouched.
16153    #[doc(hidden)]
16154    pub fn doctor_controlled<F>(
16155        &self,
16156        control: &crate::ExecutionControl,
16157        before_publish: F,
16158    ) -> Result<Vec<u64>>
16159    where
16160        F: FnOnce() -> bool,
16161    {
16162        self.doctor_controlled_with_receipt(control, before_publish)
16163            .map(|(quarantined, _)| quarantined)
16164    }
16165
16166    /// Check cancellably and return the exact catalog epoch used for a
16167    /// quarantine publication. No receipt is returned when nothing changes.
16168    #[doc(hidden)]
16169    pub fn doctor_controlled_with_receipt<F>(
16170        &self,
16171        control: &crate::ExecutionControl,
16172        before_publish: F,
16173    ) -> Result<(Vec<u64>, Option<MaintenanceReceipt>)>
16174    where
16175        F: FnOnce() -> bool,
16176    {
16177        // Hold the DDL lock for the whole operation to prevent concurrent
16178        // create_table/drop_table from racing the catalog/dir mutation.
16179        let _ddl = self.ddl_lock.lock();
16180        let _security_write = self.security_write()?;
16181        let issues = self.check_inner(Some(control))?;
16182        // A corrupt WAL segment is reported as an error but is NOT a table
16183        // problem — quarantining an innocent table cannot fix it (and the first
16184        // real table is id 0, so the WAL sentinel WAL_TABLE_ID = u64::MAX keeps
16185        // them disjoint). The admin must address WAL corruption manually.
16186        let bad_tables: std::collections::HashSet<u64> = issues
16187            .iter()
16188            .filter(|i| {
16189                i.severity == "error"
16190                    && i.table_id != WAL_TABLE_ID
16191                    && i.table_id != EXTERNAL_TABLE_ID
16192            })
16193            .map(|i| i.table_id)
16194            .collect();
16195        if bad_tables.is_empty() {
16196            return Ok((Vec::new(), None));
16197        }
16198        let _commit = self.commit_lock.lock();
16199        control.checkpoint()?;
16200        if !before_publish() {
16201            return Err(MongrelError::Cancelled);
16202        }
16203        let maintenance_epoch = self.epoch.bump_assigned();
16204        let mut epoch_guard = EpochGuard::new(self.epoch.as_ref(), maintenance_epoch);
16205
16206        let qdir = self.root.join("_quarantine");
16207        crate::durable_file::create_directory(&qdir)?;
16208        let mut bad_tables = bad_tables.into_iter().collect::<Vec<_>>();
16209        bad_tables.sort_unstable();
16210
16211        // Quiesce every mounted target before catalog publication. Existing
16212        // handle clones are marked unavailable in the publication callback so
16213        // they cannot append to the shared WAL after their catalog entry drops.
16214        let mut handles = self
16215            .tables
16216            .read()
16217            .iter()
16218            .filter(|(table_id, _)| bad_tables.binary_search(table_id).is_ok())
16219            .map(|(table_id, handle)| (*table_id, handle.clone()))
16220            .collect::<Vec<_>>();
16221        handles.sort_by_key(|(table_id, _)| *table_id);
16222        let mut table_guards = handles
16223            .iter()
16224            .map(|(table_id, handle)| (*table_id, handle.lock()))
16225            .collect::<Vec<_>>();
16226
16227        let mut next_catalog = self.catalog.read().clone();
16228        for table_id in &bad_tables {
16229            if let Some(entry) = next_catalog
16230                .tables
16231                .iter_mut()
16232                .find(|entry| entry.table_id == *table_id)
16233            {
16234                entry.state = TableState::Dropped {
16235                    at_epoch: maintenance_epoch.0,
16236                };
16237            }
16238        }
16239        next_catalog.db_epoch = next_catalog.db_epoch.max(maintenance_epoch.0);
16240
16241        let txn_id = self.alloc_txn_id()?;
16242        let commit_seq = {
16243            let mut wal = self.shared_wal.lock();
16244            let append: Result<u64> = (|| {
16245                for table_id in &bad_tables {
16246                    wal.append(
16247                        txn_id,
16248                        *table_id,
16249                        crate::wal::Op::Ddl(crate::wal::DdlOp::DropTable {
16250                            table_id: *table_id,
16251                        }),
16252                    )?;
16253                }
16254                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
16255                wal.append_commit(txn_id, maintenance_epoch, &[])
16256            })();
16257            append.map_err(|error| self.commit_outcome_unknown(maintenance_epoch, error))?
16258        };
16259        let receipt = self.await_durable_commit(txn_id, commit_seq, maintenance_epoch)?;
16260        for (_, table) in &mut table_guards {
16261            table.mark_unavailable_after_quarantine();
16262        }
16263        {
16264            let mut live_tables = self.tables.write();
16265            for table_id in &bad_tables {
16266                live_tables.remove(table_id);
16267            }
16268        }
16269        let checkpoint = self.checkpoint_catalog_after_durable(next_catalog);
16270        self.finish_durable_publish(maintenance_epoch, &mut epoch_guard, &receipt, checkpoint)?;
16271
16272        // Release DOCTOR's own table guards and handle clones before moving
16273        // the directory. Windows refuses to rename files held open by the
16274        // final mounted Table instance.
16275        drop(table_guards);
16276        drop(handles);
16277
16278        // The catalog drop is durable. Directory placement is secondary but
16279        // still uses a write-through rename. A failure reports the known
16280        // catalog outcome and leaves a harmless orphan under `tables/`.
16281        for table_id in &bad_tables {
16282            let source = self.root.join(TABLES_DIR).join(table_id.to_string());
16283            if source.exists() {
16284                let destination = qdir.join(table_id.to_string());
16285                if let Err(error) = crate::durable_file::rename(&source, &destination) {
16286                    return Err(MongrelError::DurableCommit {
16287                        epoch: maintenance_epoch.0,
16288                        message: format!(
16289                            "DOCTOR dropped table {table_id} but quarantine move failed: {error}"
16290                        ),
16291                    });
16292                }
16293            }
16294        }
16295        Ok((
16296            bad_tables,
16297            Some(MaintenanceReceipt {
16298                epoch: maintenance_epoch,
16299            }),
16300        ))
16301    }
16302
16303    /// The DB-wide KEK (if encrypted).
16304    #[allow(dead_code)]
16305    pub(crate) fn kek(&self) -> Option<&Arc<crate::encryption::Kek>> {
16306        self.kek.as_ref()
16307    }
16308
16309    /// Shared epoch authority (used by the transaction layer in P2).
16310    #[allow(dead_code)]
16311    pub(crate) fn epoch_authority(&self) -> &Arc<EpochAuthority> {
16312        &self.epoch
16313    }
16314
16315    /// Shared snapshot registry (used by GC in P3.6).
16316    #[allow(dead_code)]
16317    pub(crate) fn snapshots(&self) -> &Arc<SnapshotRegistry> {
16318        &self.snapshots
16319    }
16320}
16321
16322fn external_state_dir(root: &Path, name: &str) -> PathBuf {
16323    root.join(VTAB_DIR).join(name)
16324}
16325
16326fn append_catalog_snapshot(
16327    wal: &mut crate::wal::SharedWal,
16328    txn_id: u64,
16329    catalog: &Catalog,
16330) -> Result<()> {
16331    let catalog_json = crate::wal::DdlOp::encode_catalog(catalog)?;
16332    wal.append(
16333        txn_id,
16334        WAL_TABLE_ID,
16335        crate::wal::Op::Ddl(crate::wal::DdlOp::CatalogSnapshot { catalog_json }),
16336    )?;
16337    Ok(())
16338}
16339
16340fn filter_ignored_staging(
16341    staging: Vec<(u64, crate::txn::Staged)>,
16342    ignored_indices: &std::collections::BTreeSet<usize>,
16343) -> Vec<(u64, crate::txn::Staged)> {
16344    if ignored_indices.is_empty() {
16345        return staging;
16346    }
16347    staging
16348        .into_iter()
16349        .enumerate()
16350        .filter_map(|(idx, staged)| (!ignored_indices.contains(&idx)).then_some(staged))
16351        .collect()
16352}
16353
16354fn external_state_file(root: &Path, name: &str) -> PathBuf {
16355    external_state_dir(root, name).join("state.json")
16356}
16357
16358fn read_external_state_file(root: &Path, name: &str) -> Result<Vec<u8>> {
16359    let path = external_state_file(root, name);
16360    match std::fs::read(path) {
16361        Ok(bytes) => Ok(bytes),
16362        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
16363        Err(e) => Err(e.into()),
16364    }
16365}
16366
16367fn current_external_state_bytes(
16368    root: &Path,
16369    external_states: &[(String, Vec<u8>)],
16370    name: &str,
16371) -> Result<Vec<u8>> {
16372    for (table, state) in external_states.iter().rev() {
16373        if table == name {
16374            return Ok(state.clone());
16375        }
16376    }
16377    read_external_state_file(root, name)
16378}
16379
16380fn dedup_external_states(external_states: Vec<(String, Vec<u8>)>) -> Vec<(String, Vec<u8>)> {
16381    let mut out = external_states;
16382    dedup_external_states_in_place(&mut out);
16383    out
16384}
16385
16386fn dedup_external_states_in_place(external_states: &mut Vec<(String, Vec<u8>)>) {
16387    let mut seen = std::collections::HashSet::new();
16388    let mut out = Vec::with_capacity(external_states.len());
16389    for (name, state) in std::mem::take(external_states).into_iter().rev() {
16390        if seen.insert(name.clone()) {
16391            out.push((name, state));
16392        }
16393    }
16394    out.reverse();
16395    *external_states = out;
16396}
16397
16398fn prepare_external_state_file(
16399    root: &Path,
16400    name: &str,
16401    state: &[u8],
16402    txn_id: u64,
16403) -> Result<PathBuf> {
16404    crate::durable_file::create_directory(&root.join(VTAB_DIR))?;
16405    let dir = external_state_dir(root, name);
16406    crate::durable_file::create_directory(&dir)?;
16407    let pending = dir.join(format!("state.json.{txn_id}.tmp"));
16408    {
16409        let mut file = std::fs::OpenOptions::new()
16410            .create_new(true)
16411            .write(true)
16412            .open(&pending)?;
16413        file.write_all(state)?;
16414        file.sync_all()?;
16415    }
16416    Ok(pending)
16417}
16418
16419fn publish_external_state_file(root: &Path, name: &str, pending: &Path) -> Result<()> {
16420    let path = external_state_file(root, name);
16421    crate::durable_file::replace(pending, &path)?;
16422    Ok(())
16423}
16424
16425fn write_external_state_file(
16426    durable: &crate::durable_file::DurableRoot,
16427    name: &str,
16428    state: &[u8],
16429) -> Result<()> {
16430    let directory = Path::new(VTAB_DIR).join(name);
16431    durable.create_directory_all(&directory)?;
16432    durable.write_atomic(directory.join("state.json"), state)?;
16433    Ok(())
16434}
16435
16436fn validate_recovered_data_table(
16437    catalog: &Catalog,
16438    tables: &HashMap<u64, TableHandle>,
16439    table_id: u64,
16440    commit_epoch: u64,
16441    offset: u64,
16442) -> Result<bool> {
16443    let entry = catalog
16444        .tables
16445        .iter()
16446        .find(|entry| entry.table_id == table_id)
16447        .ok_or_else(|| MongrelError::CorruptWal {
16448            offset,
16449            reason: format!("committed record references unknown table {table_id}"),
16450        })?;
16451    if commit_epoch < entry.created_epoch {
16452        return Err(MongrelError::CorruptWal {
16453            offset,
16454            reason: format!(
16455                "table {table_id} record epoch {commit_epoch} precedes creation epoch {}",
16456                entry.created_epoch
16457            ),
16458        });
16459    }
16460    match entry.state {
16461        TableState::Dropped { at_epoch } => {
16462            // Abandoned hidden builds are marked dropped at the last durable
16463            // boundary during open, so their final build commit may equal the
16464            // cleanup epoch. Ordinary table drops consume a new epoch and must
16465            // remain strictly later than every data commit.
16466            let abandoned_build_boundary =
16467                entry.name.starts_with(CTAS_BUILD_TABLE_PREFIX) && commit_epoch == at_epoch;
16468            if commit_epoch >= at_epoch && !abandoned_build_boundary {
16469                Err(MongrelError::CorruptWal {
16470                    offset,
16471                    reason: format!(
16472                        "table {table_id} record epoch {commit_epoch} is not before drop epoch {at_epoch}"
16473                    ),
16474                })
16475            } else {
16476                Ok(false)
16477            }
16478        }
16479        TableState::Live | TableState::Building { .. } => {
16480            if tables.contains_key(&table_id) {
16481                Ok(true)
16482            } else {
16483                Err(MongrelError::CorruptWal {
16484                    offset,
16485                    reason: format!("live table {table_id} has no mounted recovery handle"),
16486                })
16487            }
16488        }
16489    }
16490}
16491
16492type RecoveryTableStage = (
16493    Vec<crate::memtable::Row>,
16494    Vec<(crate::rowid::RowId, Epoch)>,
16495    Option<Epoch>,
16496    Epoch,
16497);
16498
16499#[derive(Clone)]
16500struct RecoveryValidationTable {
16501    schema: Schema,
16502    flushed_epoch: u64,
16503}
16504
16505fn validate_shared_wal_recovery_plan(
16506    durable_root: &crate::durable_file::DurableRoot,
16507    catalog: &Catalog,
16508    recovered_table_ids: &HashSet<u64>,
16509    reconciled_table_ids: &HashSet<u64>,
16510    meta_dek: Option<&[u8; META_DEK_LEN]>,
16511    kek: Option<Arc<crate::encryption::Kek>>,
16512    records: &[crate::wal::Record],
16513) -> Result<()> {
16514    use crate::wal::{DdlOp, Op};
16515
16516    let mut tables = HashMap::<u64, RecoveryValidationTable>::new();
16517    for entry in &catalog.tables {
16518        if !matches!(entry.state, TableState::Live) {
16519            continue;
16520        }
16521        let relative_dir = Path::new(TABLES_DIR).join(entry.table_id.to_string());
16522        let manifest = match crate::manifest::read_durable(durable_root, &relative_dir, meta_dek) {
16523            Ok(manifest) => Some(manifest),
16524            Err(MongrelError::Io(error)) if error.kind() == std::io::ErrorKind::NotFound => None,
16525            Err(error) => return Err(error),
16526        };
16527        let flushed_epoch = if let Some(manifest) = manifest {
16528            if manifest.table_id != entry.table_id {
16529                return Err(MongrelError::Conflict(format!(
16530                    "catalog table {} storage identity mismatch",
16531                    entry.table_id
16532                )));
16533            }
16534            if (manifest.schema_id != entry.schema.schema_id
16535                && !reconciled_table_ids.contains(&entry.table_id))
16536                || manifest.flushed_epoch > manifest.current_epoch
16537                || manifest.global_idx_epoch > manifest.current_epoch
16538                || manifest.next_row_id == u64::MAX
16539                || manifest.auto_inc_next < 0
16540                || manifest.auto_inc_next == i64::MAX
16541                || (entry.schema.auto_increment_column().is_none() && manifest.auto_inc_next != 0)
16542            {
16543                return Err(MongrelError::InvalidArgument(format!(
16544                    "table {} manifest counters or schema identity are invalid",
16545                    entry.table_id
16546                )));
16547            }
16548            let idx_dek = kek.as_ref().map(|key| key.derive_idx_key());
16549            crate::global_idx::read_durable_for(
16550                durable_root,
16551                &relative_dir,
16552                entry.table_id,
16553                &entry.schema,
16554                idx_dek.as_deref(),
16555            )?;
16556            let mut run_ids = HashSet::new();
16557            let mut maximum_row_id = None::<u64>;
16558            for run in &manifest.runs {
16559                if run.run_id >= u64::MAX as u128
16560                    || run.epoch_created > manifest.current_epoch
16561                    || !run_ids.insert(run.run_id)
16562                {
16563                    return Err(MongrelError::InvalidArgument(format!(
16564                        "table {} manifest contains an invalid or duplicate run id",
16565                        entry.table_id
16566                    )));
16567                }
16568                let relative = relative_dir
16569                    .join(crate::engine::RUNS_DIR)
16570                    .join(format!("r-{}.sr", run.run_id as u64));
16571                let file = durable_root.open_regular(&relative)?;
16572                let mut reader = crate::sorted_run::RunReader::open_file(
16573                    file,
16574                    entry.schema.clone(),
16575                    kek.clone(),
16576                )?;
16577                let header = reader.header();
16578                if header.run_id != run.run_id
16579                    || header.level != run.level
16580                    || header.row_count != run.row_count
16581                    || !header.is_uniform_epoch() && header.epoch_created != run.epoch_created
16582                    || header.is_uniform_epoch() && header.epoch_created != 0
16583                    || header.schema_id > entry.schema.schema_id
16584                {
16585                    return Err(MongrelError::InvalidArgument(format!(
16586                        "table {} run {} differs from its manifest: header=(id {}, level {}, rows {}, epoch {}, schema {}), manifest=(id {}, level {}, rows {}, epoch {}, schema <= {})",
16587                        entry.table_id,
16588                        run.run_id,
16589                        header.run_id,
16590                        header.level,
16591                        header.row_count,
16592                        header.epoch_created,
16593                        header.schema_id,
16594                        run.run_id,
16595                        run.level,
16596                        run.row_count,
16597                        run.epoch_created,
16598                        entry.schema.schema_id,
16599                    )));
16600                }
16601                if header.row_count != 0 {
16602                    maximum_row_id = Some(
16603                        maximum_row_id
16604                            .map_or(header.max_row_id, |value| value.max(header.max_row_id)),
16605                    );
16606                }
16607                reader.validate_all_pages()?;
16608            }
16609            if maximum_row_id.is_some_and(|maximum| manifest.next_row_id <= maximum) {
16610                return Err(MongrelError::InvalidArgument(format!(
16611                    "table {} next_row_id does not advance beyond persisted rows",
16612                    entry.table_id
16613                )));
16614            }
16615            for run in &manifest.retiring {
16616                if run.run_id >= u64::MAX as u128
16617                    || run.retire_epoch > manifest.current_epoch
16618                    || !run_ids.insert(run.run_id)
16619                {
16620                    return Err(MongrelError::InvalidArgument(format!(
16621                        "table {} manifest contains an invalid or aliased retired run",
16622                        entry.table_id
16623                    )));
16624                }
16625            }
16626            manifest.flushed_epoch
16627        } else {
16628            if !recovered_table_ids.contains(&entry.table_id) {
16629                return Err(MongrelError::NotFound(format!(
16630                    "live table {} manifest is missing",
16631                    entry.table_id
16632                )));
16633            }
16634            0
16635        };
16636        tables.insert(
16637            entry.table_id,
16638            RecoveryValidationTable {
16639                schema: entry.schema.clone(),
16640                flushed_epoch,
16641            },
16642        );
16643    }
16644
16645    let committed = records
16646        .iter()
16647        .filter_map(|record| match record.op {
16648            Op::TxnCommit { epoch, .. } => Some((record.txn_id, epoch)),
16649            _ => None,
16650        })
16651        .collect::<HashMap<_, _>>();
16652    let mut run_ids = HashSet::new();
16653    let mut recovered_row_ids = HashMap::<u64, HashSet<u64>>::new();
16654    for record in records {
16655        let Some(&commit_epoch) = committed.get(&record.txn_id) else {
16656            continue;
16657        };
16658        match &record.op {
16659            Op::Put { table_id, rows } => {
16660                let table = validate_recovery_data_table_plan(
16661                    catalog,
16662                    &tables,
16663                    *table_id,
16664                    commit_epoch,
16665                    record.seq.0,
16666                )?;
16667                let decoded: Vec<crate::memtable::Row> =
16668                    bincode::deserialize(rows).map_err(|error| MongrelError::CorruptWal {
16669                        offset: record.seq.0,
16670                        reason: format!(
16671                            "committed Put payload for transaction {} could not be decoded: {error}",
16672                            record.txn_id
16673                        ),
16674                    })?;
16675                if let Some(table) = table {
16676                    for row in &decoded {
16677                        if !recovered_row_ids
16678                            .entry(*table_id)
16679                            .or_default()
16680                            .insert(row.row_id.0)
16681                        {
16682                            return Err(MongrelError::CorruptWal {
16683                                offset: record.seq.0,
16684                                reason: format!(
16685                                    "committed WAL repeats recovered row id {} for table {table_id}",
16686                                    row.row_id.0
16687                                ),
16688                            });
16689                        }
16690                        validate_recovered_row(&table.schema, row)?;
16691                    }
16692                }
16693            }
16694            Op::Delete { table_id, .. } | Op::TruncateTable { table_id } => {
16695                validate_recovery_data_table_plan(
16696                    catalog,
16697                    &tables,
16698                    *table_id,
16699                    commit_epoch,
16700                    record.seq.0,
16701                )?;
16702            }
16703            Op::ExternalTableState { name, .. } => validate_recovered_external_name(name)?,
16704            Op::Ddl(DdlOp::ResetExternalTableState {
16705                name,
16706                generation_epoch,
16707            }) => {
16708                if *generation_epoch != commit_epoch {
16709                    return Err(MongrelError::CorruptWal {
16710                        offset: record.seq.0,
16711                        reason: format!(
16712                            "external state reset epoch {generation_epoch} does not match WAL commit epoch {commit_epoch}"
16713                        ),
16714                    });
16715                }
16716                validate_recovered_external_name(name)?;
16717            }
16718            Op::TxnCommit { added_runs, .. } => {
16719                for added in added_runs {
16720                    let Some(table) = validate_recovery_data_table_plan(
16721                        catalog,
16722                        &tables,
16723                        added.table_id,
16724                        commit_epoch,
16725                        record.seq.0,
16726                    )?
16727                    else {
16728                        continue;
16729                    };
16730                    if added.run_id >= u64::MAX as u128
16731                        || !run_ids.insert((added.table_id, added.run_id))
16732                    {
16733                        return Err(MongrelError::CorruptWal {
16734                            offset: record.seq.0,
16735                            reason: format!(
16736                                "duplicate or invalid recovered run {} for table {}",
16737                                added.run_id, added.table_id
16738                            ),
16739                        });
16740                    }
16741                    if commit_epoch <= table.flushed_epoch {
16742                        continue;
16743                    }
16744                    validate_planned_spilled_run(
16745                        durable_root,
16746                        record.txn_id,
16747                        commit_epoch,
16748                        added,
16749                        &table.schema,
16750                        kek.clone(),
16751                    )?;
16752                }
16753            }
16754            _ => {}
16755        }
16756    }
16757    Ok(())
16758}
16759
16760fn validate_recovery_data_table_plan<'a>(
16761    catalog: &Catalog,
16762    tables: &'a HashMap<u64, RecoveryValidationTable>,
16763    table_id: u64,
16764    commit_epoch: u64,
16765    offset: u64,
16766) -> Result<Option<&'a RecoveryValidationTable>> {
16767    let entry = catalog
16768        .tables
16769        .iter()
16770        .find(|entry| entry.table_id == table_id)
16771        .ok_or_else(|| MongrelError::CorruptWal {
16772            offset,
16773            reason: format!("committed record references unknown table {table_id}"),
16774        })?;
16775    if commit_epoch < entry.created_epoch {
16776        return Err(MongrelError::CorruptWal {
16777            offset,
16778            reason: format!(
16779                "table {table_id} record epoch {commit_epoch} precedes creation epoch {}",
16780                entry.created_epoch
16781            ),
16782        });
16783    }
16784    match entry.state {
16785        TableState::Dropped { at_epoch } => {
16786            let abandoned =
16787                entry.name.starts_with(CTAS_BUILD_TABLE_PREFIX) && commit_epoch == at_epoch;
16788            if commit_epoch >= at_epoch && !abandoned {
16789                return Err(MongrelError::CorruptWal {
16790                    offset,
16791                    reason: format!(
16792                        "table {table_id} record epoch {commit_epoch} is not before drop epoch {at_epoch}"
16793                    ),
16794                });
16795            }
16796            Ok(None)
16797        }
16798        TableState::Live => {
16799            tables
16800                .get(&table_id)
16801                .map(Some)
16802                .ok_or_else(|| MongrelError::CorruptWal {
16803                    offset,
16804                    reason: format!("live table {table_id} has no recovery plan"),
16805                })
16806        }
16807        TableState::Building { .. } => Err(MongrelError::CorruptWal {
16808            offset,
16809            reason: format!("building table {table_id} was not normalized before recovery"),
16810        }),
16811    }
16812}
16813
16814fn validate_planned_spilled_run(
16815    root: &crate::durable_file::DurableRoot,
16816    txn_id: u64,
16817    commit_epoch: u64,
16818    added: &crate::wal::AddedRun,
16819    schema: &Schema,
16820    kek: Option<Arc<crate::encryption::Kek>>,
16821) -> Result<()> {
16822    let table = Path::new(TABLES_DIR).join(added.table_id.to_string());
16823    let destination = table
16824        .join(crate::engine::RUNS_DIR)
16825        .join(format!("r-{}.sr", added.run_id as u64));
16826    let pending = table
16827        .join("_txn")
16828        .join(txn_id.to_string())
16829        .join(format!("r-{}.sr", added.run_id as u64));
16830    let file = match root.open_regular(&destination) {
16831        Ok(file) => file,
16832        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
16833            root.open_regular(&pending).map_err(|pending_error| {
16834                if pending_error.kind() == std::io::ErrorKind::NotFound {
16835                    MongrelError::CorruptWal {
16836                        offset: commit_epoch,
16837                        reason: format!(
16838                            "committed spilled run {} for transaction {txn_id} is missing",
16839                            added.run_id
16840                        ),
16841                    }
16842                } else {
16843                    pending_error.into()
16844                }
16845            })?
16846        }
16847        Err(error) => return Err(error.into()),
16848    };
16849    let mut reader = crate::sorted_run::RunReader::open_file(file, schema.clone(), kek)?;
16850    let header = reader.header();
16851    if header.run_id != added.run_id
16852        || header.content_hash != added.content_hash
16853        || header.row_count != added.row_count
16854        || header.level != added.level
16855        || header.min_row_id != added.min_row_id
16856        || header.max_row_id != added.max_row_id
16857        || header.schema_id != schema.schema_id
16858        || !header.is_uniform_epoch()
16859        || header.epoch_created != 0
16860    {
16861        return Err(MongrelError::CorruptWal {
16862            offset: commit_epoch,
16863            reason: format!(
16864                "committed spilled run {} metadata differs from WAL",
16865                added.run_id
16866            ),
16867        });
16868    }
16869    reader.validate_all_pages()?;
16870    Ok(())
16871}
16872
16873/// Two-pass, `flushed_epoch`-gated recovery of the shared WAL (spec §15).
16874///
16875/// Pass 1 scans every `TxnCommit` marker and records `txn_id → commit_epoch`
16876/// (the per-txn outcome; aborted / in-flight / torn-tail txns are absent). Pass
16877/// 2 applies each committed data record (Put/Delete) to its table at the commit
16878/// epoch, skipping records whose `commit_epoch <= table.flushed_epoch` (already
16879/// durable in a sorted run). Finally the shared epoch authority is raised to the
16880/// max committed epoch so the next commit continues monotonically.
16881/// The staged-write payload contract of a distributed-transaction write
16882/// intent (spec section 12.8). A participant in two-phase commit stages its
16883/// writes as opaque intent payloads (`WriteIntent::value_ref` in
16884/// `mongreldb-cluster::dist_txn`); the intent layer never interprets them —
16885/// this engine-defined encoding is the whole contract. At prepare time the
16886/// payloads are validated ([`Database::validate_staged_txn_writes`]); at a
16887/// committed resolution they are applied through
16888/// [`Database::apply_staged_txn_writes`].
16889///
16890/// The encoding is bincode over this enum (the same codec the WAL frame
16891/// payloads use); discriminants are never reused.
16892#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
16893pub enum StagedTxnWrite {
16894    /// Staged row puts: bincode-serialized `Vec<crate::memtable::Row>` (the
16895    /// identical payload shape an `Op::Put` WAL record carries). Row commit
16896    /// epochs are placeholders — the resolution apply restamps every row at
16897    /// the synthetic commit epoch.
16898    Put {
16899        /// The mounted table the rows target.
16900        table_id: u64,
16901        /// Bincode `Vec<crate::memtable::Row>`.
16902        rows: Vec<u8>,
16903    },
16904    /// Staged row deletes by row id.
16905    Delete {
16906        /// The mounted table the deletes target.
16907        table_id: u64,
16908        /// Row ids (`crate::RowId` values) to delete.
16909        row_ids: Vec<u64>,
16910    },
16911}
16912
16913impl StagedTxnWrite {
16914    /// Serializes deterministically (bincode over the enum).
16915    pub fn encode(&self) -> Result<Vec<u8>> {
16916        Ok(bincode::serialize(self)?)
16917    }
16918
16919    /// Decodes one staged-write payload, failing closed on malformed input.
16920    pub fn decode(bytes: &[u8]) -> Result<Self> {
16921        Ok(bincode::deserialize(bytes)?)
16922    }
16923}
16924
16925/// Leader-side spill translation for the replicated write path (spec section
16926/// 11.3 step 3, "leader constructs transaction command"; review finding M2).
16927///
16928/// A transaction whose staged puts exceed the spill threshold commits with
16929/// its rows in a leader-local sorted run: the commit marker's `added_runs`
16930/// links the run file and the rows also ride the WAL as logical
16931/// `Op::SpilledRows` records (spec section 8.5). Run files exist only on the
16932/// leader, so a commit carrying `added_runs` is un-appliable on a replica —
16933/// and because the raft entry is already quorum-committed, an apply-time
16934/// rejection wedges the whole group's apply stream. The leader therefore
16935/// translates the staged record sequence **before proposal**:
16936///
16937/// - every `Op::SpilledRows` payload is re-tagged as an ordinary `Op::Put`
16938///   (identical row bytes; recovery restamps the rows at the commit epoch),
16939/// - the trailing `Op::TxnCommit` loses its `added_runs` (no run links ever
16940///   reach a replica),
16941/// - every other record passes through byte-identical.
16942///
16943/// The standalone commit path is untouched: the leader's own WAL keeps the
16944/// original sequence (`SpilledRows` + `added_runs`) so its recovery still
16945/// links the run; this function reads but never mutates its input.
16946///
16947/// Translation is total for any sequence the commit sequencer actually
16948/// produced. As a fail-closed guard against malformed or truncated captures,
16949/// the sequence is structurally validated (one transaction, exactly one
16950/// trailing commit marker), every spill payload must decode, and every linked
16951/// run's rows must be provably present as logical records (the row-id range
16952/// covers `row_count` rows); a violation rejects the proposal with
16953/// [`MongrelError::InvalidArgument`] — deterministic, at propose time, never
16954/// post-commit. (Taxonomy: `InvalidArgument` maps to
16955/// `ErrorCategory::ClusterVersionMismatch`, a request/binary contract
16956/// disagreement that is never retried unchanged. The normative spec category
16957/// for "the commit was not applied; only a fresh transaction may succeed" is
16958/// `CommitTooLate`; surfacing it needs a dedicated `MongrelError` variant in
16959/// `error.rs`, which is outside this change's file scope — tracked as a
16960/// follow-up.)
16961pub fn translate_records_for_replication(
16962    records: &[crate::wal::Record],
16963) -> Result<Vec<crate::wal::Record>> {
16964    use crate::wal::Op;
16965
16966    // Structural validation mirrors `apply_replicated_records`: one
16967    // transaction, exactly one commit marker, at the tail.
16968    let txn_id = records.first().map(|record| record.txn_id).ok_or_else(|| {
16969        MongrelError::InvalidArgument("replicated transaction payload is empty".into())
16970    })?;
16971    if records.iter().any(|record| record.txn_id != txn_id) {
16972        return Err(MongrelError::InvalidArgument(
16973            "replicated transaction payload mixes transaction ids".into(),
16974        ));
16975    }
16976    let commits = records
16977        .iter()
16978        .filter(|record| matches!(record.op, Op::TxnCommit { .. }))
16979        .count();
16980    if commits != 1 || !matches!(records.last().map(|r| &r.op), Some(Op::TxnCommit { .. })) {
16981        return Err(MongrelError::InvalidArgument(
16982            "replicated transaction payload must end in exactly one commit marker".into(),
16983        ));
16984    }
16985
16986    // Decode every logical spill payload now: a payload that cannot decode
16987    // here would fail every replica's apply identically — reject at propose
16988    // time instead.
16989    let mut spilled_rows: HashMap<u64, Vec<crate::memtable::Row>> = HashMap::new();
16990    for record in records {
16991        if let Op::SpilledRows { table_id, rows } = &record.op {
16992            let chunk: Vec<crate::memtable::Row> = bincode::deserialize(rows).map_err(|error| {
16993                MongrelError::InvalidArgument(format!(
16994                    "spilled row payload for table {table_id} cannot decode for replication: \
16995                         {error}"
16996                ))
16997            })?;
16998            spilled_rows.entry(*table_id).or_default().extend(chunk);
16999        }
17000    }
17001
17002    // Coverage proof: every run the commit marker links must have its full
17003    // row content present as logical records, or replicas would silently
17004    // lose those rows.
17005    let Some(Op::TxnCommit { added_runs, .. }) = records.last().map(|r| &r.op) else {
17006        unreachable!("one trailing commit marker validated above");
17007    };
17008    for run in added_runs {
17009        let rows = spilled_rows.get(&run.table_id).ok_or_else(|| {
17010            MongrelError::InvalidArgument(format!(
17011                "commit links spilled run {} for table {} but carries no logical row records \
17012                 for it",
17013                run.run_id, run.table_id
17014            ))
17015        })?;
17016        let covered = rows
17017            .iter()
17018            .filter(|row| row.row_id.0 >= run.min_row_id && row.row_id.0 <= run.max_row_id)
17019            .count() as u64;
17020        if covered != run.row_count {
17021            return Err(MongrelError::InvalidArgument(format!(
17022                "commit links spilled run {} for table {} ({} rows in [{}, {}]) but the logical \
17023                 row records cover {} rows",
17024                run.run_id, run.table_id, run.row_count, run.min_row_id, run.max_row_id, covered
17025            )));
17026        }
17027    }
17028
17029    // Translate: spill payloads become ordinary puts; the commit marker no
17030    // longer references leader-local run files.
17031    let translated = records
17032        .iter()
17033        .map(|record| {
17034            let op = match &record.op {
17035                Op::SpilledRows { table_id, rows } => Op::Put {
17036                    table_id: *table_id,
17037                    rows: rows.clone(),
17038                },
17039                Op::TxnCommit { epoch, .. } => Op::TxnCommit {
17040                    epoch: *epoch,
17041                    added_runs: Vec::new(),
17042                },
17043                op => op.clone(),
17044            };
17045            crate::wal::Record::new(record.seq, record.txn_id, op)
17046        })
17047        .collect();
17048    Ok(translated)
17049}
17050
17051fn recover_shared_wal(
17052    durable_root: &crate::durable_file::DurableRoot,
17053    tables: &HashMap<u64, TableHandle>,
17054    catalog: &Catalog,
17055    epoch: &EpochAuthority,
17056    records: &[crate::wal::Record],
17057) -> Result<()> {
17058    use crate::memtable::Row;
17059    use crate::wal::{DdlOp, Op};
17060
17061    // Pass 1: committed-txn outcomes + collect spilled-run info.
17062    let mut committed: HashMap<u64, u64> = HashMap::new();
17063    // Physical HLC micros from Op::CommitTimestamp, keyed by txn_id (P0.5).
17064    let mut commit_ts_by_txn: HashMap<u64, mongreldb_types::hlc::HlcTimestamp> = HashMap::new();
17065    let mut spilled_to_link: Vec<(
17066        u64, /*txn_id*/
17067        u64, /*epoch*/
17068        Vec<crate::wal::AddedRun>,
17069    )> = Vec::new();
17070    for r in records {
17071        match &r.op {
17072            Op::CommitTimestamp { unix_nanos } => {
17073                commit_ts_by_txn.insert(
17074                    r.txn_id,
17075                    mongreldb_types::hlc::HlcTimestamp {
17076                        physical_micros: unix_nanos / 1_000,
17077                        logical: 0,
17078                        node_tiebreaker: 0,
17079                    },
17080                );
17081            }
17082            Op::TxnCommit {
17083                epoch: ce,
17084                ref added_runs,
17085            } => {
17086                committed.insert(r.txn_id, *ce);
17087                if !added_runs.is_empty() {
17088                    spilled_to_link.push((r.txn_id, *ce, added_runs.clone()));
17089                }
17090            }
17091            _ => {}
17092        }
17093    }
17094    for record in records {
17095        let Some(&commit_epoch) = committed.get(&record.txn_id) else {
17096            continue;
17097        };
17098        match &record.op {
17099            Op::Put { table_id, .. }
17100            | Op::Delete { table_id, .. }
17101            | Op::TruncateTable { table_id } => {
17102                validate_recovered_data_table(
17103                    catalog,
17104                    tables,
17105                    *table_id,
17106                    commit_epoch,
17107                    record.seq.0,
17108                )?;
17109            }
17110            Op::TxnCommit { added_runs, .. } => {
17111                for run in added_runs {
17112                    validate_recovered_data_table(
17113                        catalog,
17114                        tables,
17115                        run.table_id,
17116                        commit_epoch,
17117                        record.seq.0,
17118                    )?;
17119                }
17120            }
17121            _ => {}
17122        }
17123    }
17124    let truncated_transactions: HashSet<(u64, u64)> = records
17125        .iter()
17126        .filter_map(|record| {
17127            committed.get(&record.txn_id)?;
17128            match record.op {
17129                Op::TruncateTable { table_id } => Some((record.txn_id, table_id)),
17130                _ => None,
17131            }
17132        })
17133        .collect();
17134
17135    // Pass 2: stage data per table, gated by flushed_epoch.
17136    enum ExternalRecoveryAction {
17137        Write { name: String, state: Vec<u8> },
17138        Reset { name: String },
17139    }
17140    let mut stage: HashMap<u64, RecoveryTableStage> = HashMap::new();
17141    let mut external_actions = Vec::new();
17142    let mut max_epoch = epoch.visible().0;
17143    for r in records.iter().cloned() {
17144        let Some(&ce) = committed.get(&r.txn_id) else {
17145            continue; // aborted / in-flight — discard
17146        };
17147        let commit_epoch = Epoch(ce);
17148        max_epoch = max_epoch.max(ce);
17149        match r.op {
17150            Op::Put { table_id, rows } => {
17151                // Skip if this table already flushed past the commit epoch.
17152                let skip = tables
17153                    .get(&table_id)
17154                    .map(|h| h.lock().flushed_epoch() >= ce)
17155                    .unwrap_or(true);
17156                if skip {
17157                    continue;
17158                }
17159                let rows: Vec<Row> = bincode::deserialize(&rows).map_err(|error| {
17160                    MongrelError::CorruptWal {
17161                        offset: r.seq.0,
17162                        reason: format!(
17163                            "committed Put payload for transaction {} could not be decoded: {error}",
17164                            r.txn_id
17165                        ),
17166                    }
17167                })?;
17168                // Re-stamp each row at the txn commit epoch (rows are pre-stamped
17169                // at pending_epoch which equals the commit epoch, but be robust).
17170                // P0.5: prefer the durable CommitTimestamp HLC for this txn when
17171                // present so recovery restores HLC-authoritative versions.
17172                let txn_commit_ts = commit_ts_by_txn.get(&r.txn_id).copied();
17173                let rows: Vec<Row> = rows
17174                    .into_iter()
17175                    .map(|mut row| {
17176                        row.committed_epoch = commit_epoch;
17177                        if row.commit_ts.is_none() {
17178                            row.commit_ts = txn_commit_ts;
17179                        }
17180                        row
17181                    })
17182                    .collect();
17183                let entry = stage
17184                    .entry(table_id)
17185                    .or_insert_with(|| (Vec::new(), Vec::new(), None, commit_epoch));
17186                entry.0.extend(rows);
17187                entry.3 = commit_epoch;
17188            }
17189            Op::Delete { table_id, row_ids } => {
17190                let skip = tables
17191                    .get(&table_id)
17192                    .map(|h| h.lock().flushed_epoch() >= ce)
17193                    .unwrap_or(true);
17194                if skip {
17195                    continue;
17196                }
17197                let dels = row_ids.into_iter().map(|rid| (rid, commit_epoch));
17198                let entry = stage
17199                    .entry(table_id)
17200                    .or_insert_with(|| (Vec::new(), Vec::new(), None, commit_epoch));
17201                entry.1.extend(dels);
17202                entry.3 = commit_epoch;
17203            }
17204            Op::TruncateTable { table_id } => {
17205                let skip = tables
17206                    .get(&table_id)
17207                    .map(|h| h.lock().flushed_epoch() >= ce)
17208                    .unwrap_or(true);
17209                if skip {
17210                    continue;
17211                }
17212                stage.insert(
17213                    table_id,
17214                    (Vec::new(), Vec::new(), Some(commit_epoch), commit_epoch),
17215                );
17216            }
17217            Op::ExternalTableState { name, state } => {
17218                let current_generation = catalog
17219                    .external_tables
17220                    .iter()
17221                    .find(|entry| entry.name == name)
17222                    .map(|entry| entry.created_epoch);
17223                if current_generation.is_some_and(|created_epoch| ce >= created_epoch) {
17224                    validate_recovered_external_name(&name)?;
17225                    external_actions.push(ExternalRecoveryAction::Write { name, state });
17226                }
17227            }
17228            Op::Ddl(DdlOp::ResetExternalTableState {
17229                name,
17230                generation_epoch,
17231            }) => {
17232                if generation_epoch != ce {
17233                    return Err(MongrelError::CorruptWal {
17234                        offset: r.seq.0,
17235                        reason: format!(
17236                        "external state reset epoch {generation_epoch} does not match WAL commit epoch {ce}"
17237                    ),
17238                    });
17239                }
17240                validate_recovered_external_name(&name)?;
17241                external_actions.push(ExternalRecoveryAction::Reset { name });
17242            }
17243            Op::Flush { .. }
17244            | Op::TxnCommit { .. }
17245            | Op::TxnAbort
17246            | Op::Ddl(_)
17247            | Op::BeforeImage { .. }
17248            | Op::CommitTimestamp { .. }
17249            | Op::SpilledRows { .. } => {}
17250        }
17251    }
17252    for (_, commit_epoch, added_runs) in &mut spilled_to_link {
17253        added_runs.retain(|added| {
17254            tables
17255                .get(&added.table_id)
17256                .is_some_and(|table| table.lock().flushed_epoch() < *commit_epoch)
17257        });
17258    }
17259    spilled_to_link.retain(|(_, _, added_runs)| !added_runs.is_empty());
17260    validate_recovery_table_stages(tables, &stage)?;
17261    validate_recovery_spilled_runs(durable_root, tables, &spilled_to_link)?;
17262
17263    // All WAL payloads, catalog generations, table stages, and immutable run
17264    // identities have now been validated. Only this application phase mutates
17265    // the database tree.
17266    for action in external_actions {
17267        match action {
17268            ExternalRecoveryAction::Write { name, state } => {
17269                write_external_state_file(durable_root, &name, &state)?;
17270            }
17271            ExternalRecoveryAction::Reset { name } => {
17272                durable_root.create_directory_all(VTAB_DIR)?;
17273                durable_root.remove_directory_all(Path::new(VTAB_DIR).join(name))?;
17274            }
17275        }
17276    }
17277    for (table_id, (rows, deletes, truncate_epoch, table_epoch)) in stage {
17278        let Some(handle) = tables.get(&table_id) else {
17279            continue;
17280        };
17281        let mut t = handle.lock();
17282        if let Some(epoch) = truncate_epoch {
17283            t.apply_truncate(epoch);
17284        }
17285        t.recover_apply(rows, deletes)?;
17286        // The WAL can be newer than the copied/persisted manifest after a
17287        // crash or replication apply. Rebuild O(1) count metadata from the
17288        // recovered state before endorsing the commit epoch in the manifest.
17289        let rows = t.visible_rows(Snapshot::unbounded())?;
17290        t.live_count = rows.len() as u64;
17291        // Recovery can replay older row commits while a newer spilled run is
17292        // already linked by the copied manifest. Never move that manifest's
17293        // epoch behind its existing run references.
17294        t.persist_manifest(table_epoch.max(epoch.visible()))?;
17295    }
17296
17297    // Pass 3: link spilled runs from committed txns (spec §8.5). A crash
17298    // between TxnCommit sync and the publish phase leaves the run in
17299    // `_txn/<txn_id>/`. Move it to `_runs/` and add the RunRef.
17300    for (txn_id, ce, added_runs) in &spilled_to_link {
17301        for ar in added_runs {
17302            let Some(handle) = tables.get(&ar.table_id) else {
17303                continue;
17304            };
17305            let mut t = handle.lock();
17306            let table_dir = Path::new(TABLES_DIR).join(ar.table_id.to_string());
17307            let destination = table_dir
17308                .join(crate::engine::RUNS_DIR)
17309                .join(format!("r-{}.sr", ar.run_id));
17310            match durable_root.open_regular(&destination) {
17311                Ok(_) => {}
17312                Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
17313                    let pending = table_dir
17314                        .join("_txn")
17315                        .join(txn_id.to_string())
17316                        .join(format!("r-{}.sr", ar.run_id));
17317                    durable_root.rename_file_new(&pending, &destination)?;
17318                }
17319                Err(error) => return Err(error.into()),
17320            }
17321            // Only link a run whose file is actually present, and never re-link
17322            // one the publish phase already persisted into the manifest (which is
17323            // the common clean-reopen case, since the `TxnCommit` lives in the WAL
17324            // until segment GC). `recover_spilled_run` is idempotent + reconciles
17325            // `live_count`/indexes only when the run is genuinely new.
17326            let linked = t.recover_spilled_run(crate::manifest::RunRef {
17327                run_id: ar.run_id,
17328                level: ar.level,
17329                epoch_created: *ce,
17330                row_count: ar.row_count,
17331            });
17332            let replaced = truncated_transactions.contains(&(*txn_id, ar.table_id));
17333            if replaced {
17334                t.set_flushed_epoch(Epoch(*ce));
17335            }
17336            if linked || replaced {
17337                t.persist_manifest(Epoch(*ce).max(epoch.visible()))?;
17338            }
17339        }
17340    }
17341
17342    epoch.advance_recovered(Epoch(max_epoch));
17343    Ok(())
17344}
17345
17346fn reconcile_recovered_table_metadata(
17347    tables: &HashMap<u64, TableHandle>,
17348    epoch: Epoch,
17349) -> Result<()> {
17350    let mut table_ids = tables.keys().copied().collect::<Vec<_>>();
17351    table_ids.sort_unstable();
17352    let mut plans = Vec::with_capacity(table_ids.len());
17353    for table_id in &table_ids {
17354        let handle = tables.get(table_id).ok_or_else(|| {
17355            MongrelError::Other(format!("mounted table {table_id} vanished during recovery"))
17356        })?;
17357        plans.push((*table_id, handle.lock().plan_recovered_metadata()?));
17358    }
17359    // Every table's data and metadata have been decoded successfully. Publish
17360    // repairs only after the complete database-wide plan is known valid.
17361    for (table_id, plan) in plans {
17362        let handle = tables.get(&table_id).ok_or_else(|| {
17363            MongrelError::Other(format!("mounted table {table_id} vanished during recovery"))
17364        })?;
17365        handle.lock().apply_recovered_metadata(plan, epoch)?;
17366    }
17367    Ok(())
17368}
17369
17370fn validate_recovered_external_name(name: &str) -> Result<()> {
17371    if name.is_empty()
17372        || !name.chars().all(|character| {
17373            character.is_ascii_alphanumeric() || character == '_' || character == '-'
17374        })
17375    {
17376        return Err(MongrelError::CorruptWal {
17377            offset: 0,
17378            reason: format!("unsafe recovered external-table name {name:?}"),
17379        });
17380    }
17381    Ok(())
17382}
17383
17384fn validate_recovery_table_stages(
17385    tables: &HashMap<u64, TableHandle>,
17386    stages: &HashMap<u64, RecoveryTableStage>,
17387) -> Result<()> {
17388    for (table_id, (rows, _, _, _)) in stages {
17389        let handle = tables
17390            .get(table_id)
17391            .ok_or_else(|| MongrelError::CorruptWal {
17392                offset: *table_id,
17393                reason: format!("recovery stage references unmounted table {table_id}"),
17394            })?;
17395        let table = handle.lock();
17396        // Force all existing immutable runs through their integrity/decode path
17397        // before any other table manifest can be changed.
17398        table.visible_rows(Snapshot::unbounded())?;
17399        for row in rows {
17400            validate_recovered_row(table.schema(), row)?;
17401        }
17402    }
17403    Ok(())
17404}
17405
17406fn validate_recovered_row(schema: &Schema, row: &crate::memtable::Row) -> Result<()> {
17407    if row.deleted || row.row_id.0 == u64::MAX {
17408        return Err(MongrelError::CorruptWal {
17409            offset: row.row_id.0,
17410            reason: "committed Put payload contains a tombstone or exhausted row id".into(),
17411        });
17412    }
17413    let cells = row
17414        .columns
17415        .iter()
17416        .map(|(column, value)| (*column, value.clone()))
17417        .collect::<Vec<_>>();
17418    schema
17419        .validate_persisted_values(&cells)
17420        .map_err(|error| MongrelError::CorruptWal {
17421            offset: row.row_id.0,
17422            reason: format!("recovered row violates table schema: {error}"),
17423        })?;
17424    if schema.auto_increment_column().is_some_and(|column| {
17425        matches!(row.columns.get(&column.id), Some(Value::Int64(value)) if *value == i64::MAX)
17426    }) {
17427        return Err(MongrelError::CorruptWal {
17428            offset: row.row_id.0,
17429            reason: "recovered AUTO_INCREMENT value exhausts i64".into(),
17430        });
17431    }
17432    Ok(())
17433}
17434
17435fn validate_recovery_spilled_runs(
17436    root: &crate::durable_file::DurableRoot,
17437    tables: &HashMap<u64, TableHandle>,
17438    spilled: &[(u64, u64, Vec<crate::wal::AddedRun>)],
17439) -> Result<()> {
17440    let mut identities = HashSet::new();
17441    for (txn_id, commit_epoch, added_runs) in spilled {
17442        for added in added_runs {
17443            if added.run_id >= u64::MAX as u128 {
17444                return Err(MongrelError::CorruptWal {
17445                    offset: *commit_epoch,
17446                    reason: format!(
17447                        "recovered run id {} exceeds the on-disk namespace",
17448                        added.run_id
17449                    ),
17450                });
17451            }
17452            let Some(handle) = tables.get(&added.table_id) else {
17453                continue;
17454            };
17455            if !identities.insert((added.table_id, added.run_id)) {
17456                return Err(MongrelError::CorruptWal {
17457                    offset: *commit_epoch,
17458                    reason: format!(
17459                        "duplicate recovered run {} for table {}",
17460                        added.run_id, added.table_id
17461                    ),
17462                });
17463            }
17464            let table = handle.lock();
17465            validate_planned_spilled_run(
17466                root,
17467                *txn_id,
17468                *commit_epoch,
17469                added,
17470                table.schema(),
17471                table.kek(),
17472            )?;
17473        }
17474    }
17475    Ok(())
17476}
17477
17478fn validate_condition_columns(condition: &ProcedureCondition, schema: &Schema) -> Result<()> {
17479    match condition {
17480        ProcedureCondition::Pk { .. } => {
17481            if schema.primary_key().is_none() {
17482                return Err(MongrelError::InvalidArgument(
17483                    "procedure condition Pk references a table without a primary key".into(),
17484                ));
17485            }
17486        }
17487        ProcedureCondition::BitmapEq { column_id, .. }
17488        | ProcedureCondition::BitmapIn { column_id, .. }
17489        | ProcedureCondition::Range { column_id, .. }
17490        | ProcedureCondition::RangeF64 { column_id, .. }
17491        | ProcedureCondition::IsNull { column_id }
17492        | ProcedureCondition::IsNotNull { column_id }
17493        | ProcedureCondition::FmContains { column_id, .. } => {
17494            validate_column_id(*column_id, schema)?;
17495        }
17496    }
17497    Ok(())
17498}
17499
17500fn bind_procedure_args(
17501    procedure: &StoredProcedure,
17502    mut args: HashMap<String, crate::Value>,
17503) -> Result<HashMap<String, crate::Value>> {
17504    let mut out = HashMap::new();
17505    for param in &procedure.params {
17506        let value = match args.remove(&param.name) {
17507            Some(value) => value,
17508            None => param.default.clone().ok_or_else(|| {
17509                MongrelError::InvalidArgument(format!(
17510                    "missing required procedure parameter {:?}",
17511                    param.name
17512                ))
17513            })?,
17514        };
17515        if !param.nullable && matches!(value, crate::Value::Null) {
17516            return Err(MongrelError::InvalidArgument(format!(
17517                "procedure parameter {:?} must not be NULL",
17518                param.name
17519            )));
17520        }
17521        if !matches!(value, crate::Value::Null) && !value_matches_type(&value, param.ty.clone()) {
17522            return Err(MongrelError::InvalidArgument(format!(
17523                "procedure parameter {:?} has wrong type",
17524                param.name
17525            )));
17526        }
17527        out.insert(param.name.clone(), value);
17528    }
17529    if let Some(extra) = args.keys().next() {
17530        return Err(MongrelError::InvalidArgument(format!(
17531            "unknown procedure parameter {extra:?}"
17532        )));
17533    }
17534    Ok(out)
17535}
17536
17537fn value_matches_type(value: &crate::Value, ty: crate::TypeId) -> bool {
17538    matches!(
17539        (value, ty),
17540        (crate::Value::Bool(_), crate::TypeId::Bool)
17541            | (crate::Value::Int64(_), crate::TypeId::Int8)
17542            | (crate::Value::Int64(_), crate::TypeId::Int16)
17543            | (crate::Value::Int64(_), crate::TypeId::Int32)
17544            | (crate::Value::Int64(_), crate::TypeId::Int64)
17545            | (crate::Value::Int64(_), crate::TypeId::UInt8)
17546            | (crate::Value::Int64(_), crate::TypeId::UInt16)
17547            | (crate::Value::Int64(_), crate::TypeId::UInt32)
17548            | (crate::Value::Int64(_), crate::TypeId::UInt64)
17549            | (crate::Value::Int64(_), crate::TypeId::TimestampNanos)
17550            | (crate::Value::Int64(_), crate::TypeId::Date32)
17551            | (crate::Value::Float64(_), crate::TypeId::Float32)
17552            | (crate::Value::Float64(_), crate::TypeId::Float64)
17553            | (crate::Value::Bytes(_), crate::TypeId::Bytes)
17554            | (crate::Value::Embedding(_), crate::TypeId::Embedding { .. })
17555            | (
17556                crate::Value::GeneratedEmbedding(_),
17557                crate::TypeId::Embedding { .. }
17558            )
17559    )
17560}
17561
17562fn eval_cells(
17563    cells: &[crate::procedure::ProcedureCell],
17564    args: &HashMap<String, crate::Value>,
17565    outputs: &HashMap<String, ProcedureCallOutput>,
17566) -> Result<Vec<(u16, crate::Value)>> {
17567    cells
17568        .iter()
17569        .map(|cell| Ok((cell.column_id, eval_value(&cell.value, args, outputs)?)))
17570        .collect()
17571}
17572
17573fn eval_condition(
17574    condition: &ProcedureCondition,
17575    args: &HashMap<String, crate::Value>,
17576    outputs: &HashMap<String, ProcedureCallOutput>,
17577) -> Result<crate::Condition> {
17578    Ok(match condition {
17579        ProcedureCondition::Pk { value } => {
17580            crate::Condition::Pk(eval_value(value, args, outputs)?.encode_key())
17581        }
17582        ProcedureCondition::BitmapEq { column_id, value } => crate::Condition::BitmapEq {
17583            column_id: *column_id,
17584            value: eval_value(value, args, outputs)?.encode_key(),
17585        },
17586        ProcedureCondition::BitmapIn { column_id, values } => crate::Condition::BitmapIn {
17587            column_id: *column_id,
17588            values: values
17589                .iter()
17590                .map(|value| Ok(eval_value(value, args, outputs)?.encode_key()))
17591                .collect::<Result<Vec<_>>>()?,
17592        },
17593        ProcedureCondition::Range { column_id, lo, hi } => crate::Condition::Range {
17594            column_id: *column_id,
17595            lo: expect_i64(eval_value(lo, args, outputs)?)?,
17596            hi: expect_i64(eval_value(hi, args, outputs)?)?,
17597        },
17598        ProcedureCondition::RangeF64 {
17599            column_id,
17600            lo,
17601            lo_inclusive,
17602            hi,
17603            hi_inclusive,
17604        } => crate::Condition::RangeF64 {
17605            column_id: *column_id,
17606            lo: expect_f64(eval_value(lo, args, outputs)?)?,
17607            lo_inclusive: *lo_inclusive,
17608            hi: expect_f64(eval_value(hi, args, outputs)?)?,
17609            hi_inclusive: *hi_inclusive,
17610        },
17611        ProcedureCondition::IsNull { column_id } => crate::Condition::IsNull {
17612            column_id: *column_id,
17613        },
17614        ProcedureCondition::IsNotNull { column_id } => crate::Condition::IsNotNull {
17615            column_id: *column_id,
17616        },
17617        ProcedureCondition::FmContains { column_id, pattern } => crate::Condition::FmContains {
17618            column_id: *column_id,
17619            pattern: expect_bytes(eval_value(pattern, args, outputs)?)?,
17620        },
17621    })
17622}
17623
17624fn eval_value(
17625    value: &ProcedureValue,
17626    args: &HashMap<String, crate::Value>,
17627    outputs: &HashMap<String, ProcedureCallOutput>,
17628) -> Result<crate::Value> {
17629    match value {
17630        ProcedureValue::Literal(value) => Ok(value.clone()),
17631        ProcedureValue::Param(name) => args.get(name).cloned().ok_or_else(|| {
17632            MongrelError::InvalidArgument(format!("unknown procedure parameter {name:?}"))
17633        }),
17634        ProcedureValue::StepScalar(id) => match outputs.get(id) {
17635            Some(ProcedureCallOutput::Scalar(value)) => Ok(value.clone()),
17636            _ => Err(MongrelError::InvalidArgument(format!(
17637                "procedure step {id:?} did not return a scalar"
17638            ))),
17639        },
17640        ProcedureValue::StepRows(_) | ProcedureValue::StepRow(_) => {
17641            Err(MongrelError::InvalidArgument(
17642                "row-valued procedure reference cannot be used as a scalar".into(),
17643            ))
17644        }
17645        ProcedureValue::Object(_) | ProcedureValue::Array(_) => Err(MongrelError::InvalidArgument(
17646            "structured procedure value cannot be used as a scalar cell".into(),
17647        )),
17648    }
17649}
17650
17651fn eval_return_output(
17652    value: &ProcedureValue,
17653    args: &HashMap<String, crate::Value>,
17654    outputs: &HashMap<String, ProcedureCallOutput>,
17655) -> Result<ProcedureCallOutput> {
17656    match value {
17657        ProcedureValue::Literal(value) => Ok(ProcedureCallOutput::Scalar(value.clone())),
17658        ProcedureValue::Param(name) => Ok(ProcedureCallOutput::Scalar(
17659            args.get(name).cloned().ok_or_else(|| {
17660                MongrelError::InvalidArgument(format!("unknown procedure parameter {name:?}"))
17661            })?,
17662        )),
17663        ProcedureValue::StepRows(id)
17664        | ProcedureValue::StepRow(id)
17665        | ProcedureValue::StepScalar(id) => outputs.get(id).cloned().ok_or_else(|| {
17666            MongrelError::InvalidArgument(format!("unknown procedure step output {id:?}"))
17667        }),
17668        ProcedureValue::Object(fields) => {
17669            let mut out = Vec::with_capacity(fields.len());
17670            for (name, value) in fields {
17671                out.push((name.clone(), eval_return_output(value, args, outputs)?));
17672            }
17673            Ok(ProcedureCallOutput::Object(out))
17674        }
17675        ProcedureValue::Array(values) => {
17676            let mut out = Vec::with_capacity(values.len());
17677            for value in values {
17678                out.push(eval_return_output(value, args, outputs)?);
17679            }
17680            Ok(ProcedureCallOutput::Array(out))
17681        }
17682    }
17683}
17684
17685fn expect_i64(value: crate::Value) -> Result<i64> {
17686    match value {
17687        crate::Value::Int64(value) => Ok(value),
17688        _ => Err(MongrelError::InvalidArgument(
17689            "procedure value must be Int64".into(),
17690        )),
17691    }
17692}
17693
17694fn expect_f64(value: crate::Value) -> Result<f64> {
17695    match value {
17696        crate::Value::Float64(value) => Ok(value),
17697        _ => Err(MongrelError::InvalidArgument(
17698            "procedure value must be Float64".into(),
17699        )),
17700    }
17701}
17702
17703fn expect_bytes(value: crate::Value) -> Result<Vec<u8>> {
17704    match value {
17705        crate::Value::Bytes(value) => Ok(value),
17706        _ => Err(MongrelError::InvalidArgument(
17707            "procedure value must be Bytes".into(),
17708        )),
17709    }
17710}
17711
17712fn validate_column_id(column_id: u16, schema: &Schema) -> Result<()> {
17713    if schema.columns.iter().any(|c| c.id == column_id) {
17714        Ok(())
17715    } else {
17716        Err(MongrelError::InvalidArgument(format!(
17717            "unknown column id {column_id}"
17718        )))
17719    }
17720}
17721
17722fn trigger_matches_event(
17723    trigger: &StoredTrigger,
17724    event: &WriteEvent,
17725    cat: &Catalog,
17726) -> Result<bool> {
17727    if trigger.event != event.kind {
17728        return Ok(false);
17729    }
17730    let TriggerTarget::Table(target) = &trigger.target else {
17731        return Ok(false);
17732    };
17733    if target != &event.table {
17734        return Ok(false);
17735    }
17736    if trigger.event == TriggerEvent::Update && !trigger.update_of.is_empty() {
17737        let schema = &cat
17738            .live(target)
17739            .ok_or_else(|| {
17740                MongrelError::InvalidArgument(format!(
17741                    "trigger {:?} references unknown table {target:?}",
17742                    trigger.name
17743                ))
17744            })?
17745            .schema;
17746        let mut watched = Vec::with_capacity(trigger.update_of.len());
17747        for name in &trigger.update_of {
17748            let col = schema.column(name).ok_or_else(|| {
17749                MongrelError::InvalidArgument(format!(
17750                    "trigger {:?} references unknown UPDATE OF column {name:?}",
17751                    trigger.name
17752                ))
17753            })?;
17754            watched.push(col.id);
17755        }
17756        if !event
17757            .changed_columns
17758            .iter()
17759            .any(|column_id| watched.contains(column_id))
17760        {
17761            return Ok(false);
17762        }
17763    }
17764    Ok(true)
17765}
17766
17767fn changed_columns(old: Option<&TriggerRowImage>, new: Option<&TriggerRowImage>) -> Vec<u16> {
17768    let mut ids = std::collections::BTreeSet::new();
17769    if let Some(old) = old {
17770        ids.extend(old.columns.keys().copied());
17771    }
17772    if let Some(new) = new {
17773        ids.extend(new.columns.keys().copied());
17774    }
17775    ids.into_iter()
17776        .filter(|id| {
17777            old.and_then(|row| row.columns.get(id)) != new.and_then(|row| row.columns.get(id))
17778        })
17779        .collect()
17780}
17781
17782fn eval_trigger_cells(
17783    cells: &[crate::trigger::TriggerCell],
17784    event: &WriteEvent,
17785    selected: Option<&TriggerRowImage>,
17786) -> Result<Vec<(u16, Value)>> {
17787    cells
17788        .iter()
17789        .map(|cell| {
17790            Ok((
17791                cell.column_id,
17792                eval_trigger_value(&cell.value, event, selected)?,
17793            ))
17794        })
17795        .collect()
17796}
17797
17798fn eval_trigger_expr(expr: &TriggerExpr, event: &WriteEvent) -> Result<bool> {
17799    match expr {
17800        TriggerExpr::Value(value) => match eval_trigger_value(value, event, None)? {
17801            Value::Bool(value) => Ok(value),
17802            Value::Null => Ok(false),
17803            other => Err(MongrelError::InvalidArgument(format!(
17804                "trigger WHEN value must be boolean, got {other:?}"
17805            ))),
17806        },
17807        TriggerExpr::Eq { left, right } => Ok(values_equal(
17808            &eval_trigger_value(left, event, None)?,
17809            &eval_trigger_value(right, event, None)?,
17810        )),
17811        TriggerExpr::NotEq { left, right } => Ok(!values_equal(
17812            &eval_trigger_value(left, event, None)?,
17813            &eval_trigger_value(right, event, None)?,
17814        )),
17815        TriggerExpr::Lt { left, right } => match value_order(
17816            &eval_trigger_value(left, event, None)?,
17817            &eval_trigger_value(right, event, None)?,
17818        ) {
17819            Some(ordering) => Ok(ordering == std::cmp::Ordering::Less),
17820            None => Ok(false),
17821        },
17822        TriggerExpr::Lte { left, right } => match value_order(
17823            &eval_trigger_value(left, event, None)?,
17824            &eval_trigger_value(right, event, None)?,
17825        ) {
17826            Some(ordering) => Ok(ordering != std::cmp::Ordering::Greater),
17827            None => Ok(false),
17828        },
17829        TriggerExpr::Gt { left, right } => match value_order(
17830            &eval_trigger_value(left, event, None)?,
17831            &eval_trigger_value(right, event, None)?,
17832        ) {
17833            Some(ordering) => Ok(ordering == std::cmp::Ordering::Greater),
17834            None => Ok(false),
17835        },
17836        TriggerExpr::Gte { left, right } => match value_order(
17837            &eval_trigger_value(left, event, None)?,
17838            &eval_trigger_value(right, event, None)?,
17839        ) {
17840            Some(ordering) => Ok(ordering != std::cmp::Ordering::Less),
17841            None => Ok(false),
17842        },
17843        TriggerExpr::IsNull(value) => Ok(matches!(
17844            eval_trigger_value(value, event, None)?,
17845            Value::Null
17846        )),
17847        TriggerExpr::IsNotNull(value) => Ok(!matches!(
17848            eval_trigger_value(value, event, None)?,
17849            Value::Null
17850        )),
17851        TriggerExpr::And { left, right } => {
17852            if !eval_trigger_expr(left, event)? {
17853                Ok(false)
17854            } else {
17855                Ok(eval_trigger_expr(right, event)?)
17856            }
17857        }
17858        TriggerExpr::Or { left, right } => {
17859            if eval_trigger_expr(left, event)? {
17860                Ok(true)
17861            } else {
17862                Ok(eval_trigger_expr(right, event)?)
17863            }
17864        }
17865        TriggerExpr::Not(expr) => Ok(!eval_trigger_expr(expr, event)?),
17866    }
17867}
17868
17869fn eval_trigger_condition(
17870    condition: &TriggerCondition,
17871    event: &WriteEvent,
17872    selected: &TriggerRowImage,
17873    schema: &Schema,
17874) -> Result<bool> {
17875    match condition {
17876        TriggerCondition::Pk { value } => {
17877            let pk = schema.primary_key().ok_or_else(|| {
17878                MongrelError::InvalidArgument(
17879                    "trigger condition Pk references a table without a primary key".into(),
17880                )
17881            })?;
17882            let lhs = eval_trigger_value(value, event, Some(selected))?;
17883            Ok(values_equal(
17884                &lhs,
17885                selected.columns.get(&pk.id).unwrap_or(&Value::Null),
17886            ))
17887        }
17888        TriggerCondition::Eq { column_id, value } => Ok(values_equal(
17889            selected.columns.get(column_id).unwrap_or(&Value::Null),
17890            &eval_trigger_value(value, event, Some(selected))?,
17891        )),
17892        TriggerCondition::NotEq { column_id, value } => Ok(!values_equal(
17893            selected.columns.get(column_id).unwrap_or(&Value::Null),
17894            &eval_trigger_value(value, event, Some(selected))?,
17895        )),
17896        TriggerCondition::Lt { column_id, value } => match value_order(
17897            selected.columns.get(column_id).unwrap_or(&Value::Null),
17898            &eval_trigger_value(value, event, Some(selected))?,
17899        ) {
17900            Some(ordering) => Ok(ordering == std::cmp::Ordering::Less),
17901            None => Ok(false),
17902        },
17903        TriggerCondition::Lte { column_id, value } => match value_order(
17904            selected.columns.get(column_id).unwrap_or(&Value::Null),
17905            &eval_trigger_value(value, event, Some(selected))?,
17906        ) {
17907            Some(ordering) => Ok(ordering != std::cmp::Ordering::Greater),
17908            None => Ok(false),
17909        },
17910        TriggerCondition::Gt { column_id, value } => match value_order(
17911            selected.columns.get(column_id).unwrap_or(&Value::Null),
17912            &eval_trigger_value(value, event, Some(selected))?,
17913        ) {
17914            Some(ordering) => Ok(ordering == std::cmp::Ordering::Greater),
17915            None => Ok(false),
17916        },
17917        TriggerCondition::Gte { column_id, value } => match value_order(
17918            selected.columns.get(column_id).unwrap_or(&Value::Null),
17919            &eval_trigger_value(value, event, Some(selected))?,
17920        ) {
17921            Some(ordering) => Ok(ordering != std::cmp::Ordering::Less),
17922            None => Ok(false),
17923        },
17924        TriggerCondition::IsNull { column_id } => Ok(matches!(
17925            selected.columns.get(column_id),
17926            None | Some(Value::Null)
17927        )),
17928        TriggerCondition::IsNotNull { column_id } => Ok(!matches!(
17929            selected.columns.get(column_id),
17930            None | Some(Value::Null)
17931        )),
17932        TriggerCondition::And { left, right } => {
17933            if !eval_trigger_condition(left, event, selected, schema)? {
17934                Ok(false)
17935            } else {
17936                Ok(eval_trigger_condition(right, event, selected, schema)?)
17937            }
17938        }
17939        TriggerCondition::Or { left, right } => {
17940            if eval_trigger_condition(left, event, selected, schema)? {
17941                Ok(true)
17942            } else {
17943                Ok(eval_trigger_condition(right, event, selected, schema)?)
17944            }
17945        }
17946        TriggerCondition::Not(condition) => {
17947            Ok(!eval_trigger_condition(condition, event, selected, schema)?)
17948        }
17949    }
17950}
17951
17952fn eval_trigger_value(
17953    value: &TriggerValue,
17954    event: &WriteEvent,
17955    selected: Option<&TriggerRowImage>,
17956) -> Result<Value> {
17957    match value {
17958        TriggerValue::Literal(value) => Ok(value.clone()),
17959        TriggerValue::NewColumn(column_id) => event
17960            .new
17961            .as_ref()
17962            .and_then(|row| row.columns.get(column_id))
17963            .cloned()
17964            .ok_or_else(|| MongrelError::InvalidArgument("NEW column is not available".into())),
17965        TriggerValue::OldColumn(column_id) => event
17966            .old
17967            .as_ref()
17968            .and_then(|row| row.columns.get(column_id))
17969            .cloned()
17970            .ok_or_else(|| MongrelError::InvalidArgument("OLD column is not available".into())),
17971        TriggerValue::SelectedColumn(column_id) => selected
17972            .and_then(|row| row.columns.get(column_id))
17973            .cloned()
17974            .ok_or_else(|| {
17975                MongrelError::InvalidArgument("SELECTED column is not available".into())
17976            }),
17977    }
17978}
17979
17980fn values_equal(left: &Value, right: &Value) -> bool {
17981    match (left, right) {
17982        (Value::Null, Value::Null) => true,
17983        (Value::Bool(a), Value::Bool(b)) => a == b,
17984        (Value::Int64(a), Value::Int64(b)) => a == b,
17985        (Value::Float64(a), Value::Float64(b)) => a.to_bits() == b.to_bits(),
17986        (Value::Bytes(a), Value::Bytes(b)) => a == b,
17987        (a, b) if a.as_embedding().is_some() && b.as_embedding().is_some() => {
17988            let a = a.as_embedding().unwrap();
17989            let b = b.as_embedding().unwrap();
17990            a.len() == b.len()
17991                && a.iter()
17992                    .zip(b.iter())
17993                    .all(|(a, b)| a.to_bits() == b.to_bits())
17994        }
17995        _ => false,
17996    }
17997}
17998
17999fn value_order(left: &Value, right: &Value) -> Option<std::cmp::Ordering> {
18000    match (left, right) {
18001        (Value::Null, _) | (_, Value::Null) => None,
18002        (Value::Bool(a), Value::Bool(b)) => Some(a.cmp(b)),
18003        (Value::Int64(a), Value::Int64(b)) => Some(a.cmp(b)),
18004        // Cross-type Int64/Float64 comparison coerces the integer to f64.
18005        // This matches the spec but can lose precision for i64 values above 2^53.
18006        (Value::Int64(a), Value::Float64(b)) => {
18007            let af = *a as f64;
18008            Some(af.total_cmp(b))
18009        }
18010        // Cross-type Int64/Float64 comparison coerces the integer to f64.
18011        // This matches the spec but can lose precision for i64 values above 2^53.
18012        (Value::Float64(a), Value::Int64(b)) => {
18013            let bf = *b as f64;
18014            Some(a.total_cmp(&bf))
18015        }
18016        (Value::Float64(a), Value::Float64(b)) => Some(a.total_cmp(b)),
18017        (Value::Bytes(a), Value::Bytes(b)) => Some(a.cmp(b)),
18018        (a, b) if a.as_embedding().is_some() && b.as_embedding().is_some() => None,
18019        _ => None,
18020    }
18021}
18022
18023fn trigger_message(value: Value) -> String {
18024    match value {
18025        Value::Null => "NULL".into(),
18026        Value::Bool(value) => value.to_string(),
18027        Value::Int64(value) => value.to_string(),
18028        Value::Float64(value) => value.to_string(),
18029        Value::Bytes(value) => String::from_utf8_lossy(&value).into_owned(),
18030        Value::Embedding(value) => format!("{value:?}"),
18031        Value::GeneratedEmbedding(value) => format!("{:?}", value.vector),
18032        Value::Decimal(value) => value.to_string(),
18033        Value::Interval {
18034            months,
18035            days,
18036            nanos,
18037        } => format!("{months}m {days}d {nanos}ns"),
18038        Value::Uuid(b) => b.iter().map(|x| format!("{x:02x}")).collect(),
18039        Value::Json(b) => String::from_utf8_lossy(&b).into_owned(),
18040    }
18041}
18042
18043fn validate_trigger_step<'a>(
18044    step: &TriggerStep,
18045    cat: &'a Catalog,
18046    target_schema: &Schema,
18047    event: TriggerEvent,
18048    select_schemas: &mut HashMap<String, &'a Schema>,
18049) -> Result<()> {
18050    match step {
18051        TriggerStep::SetNew { cells } => {
18052            if event == TriggerEvent::Delete {
18053                return Err(MongrelError::InvalidArgument(
18054                    "SetNew trigger step is not valid for DELETE triggers".into(),
18055                ));
18056            }
18057            for cell in cells {
18058                validate_column_id(cell.column_id, target_schema)?;
18059                validate_trigger_value(&cell.value, target_schema, event)?;
18060            }
18061        }
18062        TriggerStep::Insert { table, cells } => {
18063            let schema = trigger_write_schema(cat, table, "insert")?;
18064            for cell in cells {
18065                validate_column_id(cell.column_id, schema)?;
18066                validate_trigger_value(&cell.value, target_schema, event)?;
18067            }
18068        }
18069        TriggerStep::UpdateByPk { table, pk, cells } => {
18070            let schema = trigger_write_schema(cat, table, "update")?;
18071            if schema.primary_key().is_none() {
18072                return Err(MongrelError::InvalidArgument(format!(
18073                    "trigger update_by_pk references table {table:?} without a primary key"
18074                )));
18075            }
18076            validate_trigger_value(pk, target_schema, event)?;
18077            for cell in cells {
18078                validate_column_id(cell.column_id, schema)?;
18079                validate_trigger_value(&cell.value, target_schema, event)?;
18080            }
18081        }
18082        TriggerStep::DeleteByPk { table, pk } => {
18083            let schema = trigger_write_schema(cat, table, "delete")?;
18084            if schema.primary_key().is_none() {
18085                return Err(MongrelError::InvalidArgument(format!(
18086                    "trigger delete_by_pk references table {table:?} without a primary key"
18087                )));
18088            }
18089            validate_trigger_value(pk, target_schema, event)?;
18090        }
18091        TriggerStep::Select {
18092            id,
18093            table,
18094            conditions,
18095        } => {
18096            let schema = trigger_read_schema(cat, table)?;
18097            for condition in conditions {
18098                validate_trigger_condition(condition, schema, target_schema, event)?;
18099            }
18100            if select_schemas.contains_key(id) {
18101                return Err(MongrelError::InvalidArgument(format!(
18102                    "duplicate select id {id:?} in trigger program"
18103                )));
18104            }
18105            select_schemas.insert(id.clone(), schema);
18106        }
18107        TriggerStep::Foreach { id, steps } => {
18108            if !select_schemas.contains_key(id) {
18109                return Err(MongrelError::InvalidArgument(format!(
18110                    "foreach references unknown select id {id:?}"
18111                )));
18112            }
18113            let mut inner_select_schemas = select_schemas.clone();
18114            for step in steps {
18115                validate_trigger_step(step, cat, target_schema, event, &mut inner_select_schemas)?;
18116            }
18117        }
18118        TriggerStep::DeleteWhere { table, conditions } => {
18119            let schema = trigger_write_schema(cat, table, "delete")?;
18120            for condition in conditions {
18121                validate_trigger_condition(condition, schema, target_schema, event)?;
18122            }
18123        }
18124        TriggerStep::UpdateWhere {
18125            table,
18126            conditions,
18127            cells,
18128        } => {
18129            let schema = trigger_write_schema(cat, table, "update")?;
18130            for condition in conditions {
18131                validate_trigger_condition(condition, schema, target_schema, event)?;
18132            }
18133            for cell in cells {
18134                validate_column_id(cell.column_id, schema)?;
18135                validate_trigger_value(&cell.value, target_schema, event)?;
18136            }
18137        }
18138        TriggerStep::Raise { message, .. } => {
18139            validate_trigger_value(message, target_schema, event)?
18140        }
18141    }
18142    Ok(())
18143}
18144
18145fn trigger_validation_error(error: MongrelError) -> MongrelError {
18146    match error {
18147        MongrelError::TriggerValidation(_) => error,
18148        MongrelError::InvalidArgument(message)
18149        | MongrelError::Conflict(message)
18150        | MongrelError::NotFound(message) => MongrelError::TriggerValidation(message),
18151        error => error,
18152    }
18153}
18154
18155fn trigger_write_schema<'a>(cat: &'a Catalog, table: &str, op: &str) -> Result<&'a Schema> {
18156    if let Some(entry) = cat.live(table) {
18157        return Ok(&entry.schema);
18158    }
18159    if let Some(entry) = cat.external_tables.iter().find(|entry| entry.name == table) {
18160        let allowed = match op {
18161            "insert" => entry.capabilities.writable || entry.capabilities.insert_only,
18162            "update" | "delete" => entry.capabilities.writable,
18163            _ => false,
18164        };
18165        if !allowed {
18166            return Err(MongrelError::InvalidArgument(format!(
18167                "trigger {op} references external table {table:?}, but module {:?} is not writable for that operation",
18168                entry.module
18169            )));
18170        }
18171        if !entry.capabilities.transaction_safe {
18172            return Err(MongrelError::InvalidArgument(format!(
18173                "trigger {op} references external table {table:?}, but module {:?} is not transaction-safe",
18174                entry.module
18175            )));
18176        }
18177        return Ok(&entry.declared_schema);
18178    }
18179    Err(MongrelError::InvalidArgument(format!(
18180        "trigger references unknown table {table:?}"
18181    )))
18182}
18183
18184fn trigger_read_schema<'a>(cat: &'a Catalog, table: &str) -> Result<&'a Schema> {
18185    if let Some(entry) = cat.live(table) {
18186        return Ok(&entry.schema);
18187    }
18188    if let Some(entry) = cat.external_tables.iter().find(|entry| entry.name == table) {
18189        if entry.capabilities.trigger_safe {
18190            return Ok(&entry.declared_schema);
18191        }
18192        return Err(MongrelError::InvalidArgument(format!(
18193            "trigger reads external table {table:?}, but module {:?} is not trigger-safe",
18194            entry.module
18195        )));
18196    }
18197    Err(MongrelError::InvalidArgument(format!(
18198        "trigger references unknown table {table:?}"
18199    )))
18200}
18201
18202fn validate_trigger_condition(
18203    condition: &TriggerCondition,
18204    schema: &Schema,
18205    target_schema: &Schema,
18206    event: TriggerEvent,
18207) -> Result<()> {
18208    match condition {
18209        TriggerCondition::Pk { value } => {
18210            if schema.primary_key().is_none() {
18211                return Err(MongrelError::InvalidArgument(
18212                    "trigger condition Pk references a table without a primary key".into(),
18213                ));
18214            }
18215            validate_trigger_value(value, target_schema, event)
18216        }
18217        TriggerCondition::Eq { column_id, value }
18218        | TriggerCondition::NotEq { column_id, value }
18219        | TriggerCondition::Lt { column_id, value }
18220        | TriggerCondition::Lte { column_id, value }
18221        | TriggerCondition::Gt { column_id, value }
18222        | TriggerCondition::Gte { column_id, value } => {
18223            validate_column_id(*column_id, schema)?;
18224            validate_trigger_value(value, target_schema, event)
18225        }
18226        TriggerCondition::IsNull { column_id } | TriggerCondition::IsNotNull { column_id } => {
18227            validate_column_id(*column_id, schema)
18228        }
18229        TriggerCondition::And { left, right } | TriggerCondition::Or { left, right } => {
18230            validate_trigger_condition(left, schema, target_schema, event)?;
18231            validate_trigger_condition(right, schema, target_schema, event)
18232        }
18233        TriggerCondition::Not(condition) => {
18234            validate_trigger_condition(condition, schema, target_schema, event)
18235        }
18236    }
18237}
18238
18239fn validate_trigger_expr(expr: &TriggerExpr, schema: &Schema, event: TriggerEvent) -> Result<()> {
18240    match expr {
18241        TriggerExpr::Value(value) | TriggerExpr::IsNull(value) | TriggerExpr::IsNotNull(value) => {
18242            validate_trigger_value(value, schema, event)
18243        }
18244        TriggerExpr::Eq { left, right }
18245        | TriggerExpr::NotEq { left, right }
18246        | TriggerExpr::Lt { left, right }
18247        | TriggerExpr::Lte { left, right }
18248        | TriggerExpr::Gt { left, right }
18249        | TriggerExpr::Gte { left, right } => {
18250            validate_trigger_value(left, schema, event)?;
18251            validate_trigger_value(right, schema, event)
18252        }
18253        TriggerExpr::And { left, right } | TriggerExpr::Or { left, right } => {
18254            validate_trigger_expr(left, schema, event)?;
18255            validate_trigger_expr(right, schema, event)
18256        }
18257        TriggerExpr::Not(expr) => validate_trigger_expr(expr, schema, event),
18258    }
18259}
18260
18261fn validate_trigger_value(
18262    value: &TriggerValue,
18263    schema: &Schema,
18264    event: TriggerEvent,
18265) -> Result<()> {
18266    match value {
18267        TriggerValue::Literal(_) => Ok(()),
18268        TriggerValue::NewColumn(id) => {
18269            if event == TriggerEvent::Delete {
18270                return Err(MongrelError::InvalidArgument(
18271                    "DELETE triggers cannot reference NEW".into(),
18272                ));
18273            }
18274            validate_column_id(*id, schema)
18275        }
18276        TriggerValue::OldColumn(id) => {
18277            if event == TriggerEvent::Insert {
18278                return Err(MongrelError::InvalidArgument(
18279                    "INSERT triggers cannot reference OLD".into(),
18280                ));
18281            }
18282            validate_column_id(*id, schema)
18283        }
18284        // SELECTED column references are only meaningful inside a foreach loop.
18285        // Strict loop-scope validation is deferred to runtime; the executor raises
18286        // an error if a selected row is not available.
18287        TriggerValue::SelectedColumn(_) => Ok(()),
18288    }
18289}
18290
18291/// Bound on the retained tail of the per-open commit-timestamp ledger
18292/// ([`Database::commit_ts_for_epoch`]): the read-your-writes lookup only ever
18293/// needs recent epochs, so the map keeps the newest commits and drops the
18294/// rest (a miss falls back to a fresh-begin HLC at the caller).
18295const COMMIT_TS_LEDGER_CAP: usize = 10_000;
18296
18297/// Rebuild the per-open epoch → commit-timestamp ledger from the validated
18298/// WAL recovery plan: `Op::TxnCommit` maps a transaction to its commit epoch
18299/// and the `Op::CommitTimestamp` record written ahead of it carries the
18300/// physical HLC component as nanos (spec §8.1). Reconstructed timestamps
18301/// carry `logical`/`node_tiebreaker` as 0 — the ledger byte format stores
18302/// micros only (same constraint [`crate::commit_log`] documents for replayed
18303/// receipts). Only the newest [`COMMIT_TS_LEDGER_CAP`] epochs are retained.
18304fn commit_ts_ledger_from_recovery(
18305    records: &[crate::wal::Record],
18306) -> std::collections::BTreeMap<u64, mongreldb_types::hlc::HlcTimestamp> {
18307    use crate::wal::Op;
18308    let mut commits = HashMap::new();
18309    let mut timestamps = HashMap::new();
18310    for record in records {
18311        match &record.op {
18312            Op::TxnCommit { epoch, .. } => {
18313                commits.insert(record.txn_id, *epoch);
18314            }
18315            Op::CommitTimestamp { unix_nanos } => {
18316                timestamps.insert(record.txn_id, *unix_nanos);
18317            }
18318            _ => {}
18319        }
18320    }
18321    let mut ledger = std::collections::BTreeMap::new();
18322    for (txn_id, epoch) in commits {
18323        let Some(unix_nanos) = timestamps.get(&txn_id) else {
18324            continue;
18325        };
18326        ledger.insert(
18327            epoch,
18328            mongreldb_types::hlc::HlcTimestamp {
18329                physical_micros: unix_nanos / 1_000,
18330                logical: 0,
18331                node_tiebreaker: 0,
18332            },
18333        );
18334    }
18335    while ledger.len() > COMMIT_TS_LEDGER_CAP {
18336        ledger.pop_first();
18337    }
18338    ledger
18339}
18340
18341/// Replay committed `Op::Ddl` records from the shared WAL into the catalog
18342/// (spec §15, review fix #16). A crash between WAL group-sync and the catalog
18343/// checkpoint leaves DDL durable in the WAL but absent from the on-disk
18344/// catalog. This pass closes that window by reconstructing missing entries
18345/// (and marking committed drops) before tables are mounted.
18346fn recover_ddl_from_wal(
18347    root: &Path,
18348    durable_root: Option<&crate::durable_file::DurableRoot>,
18349    target_catalog: &mut Catalog,
18350    meta_dek: Option<&[u8; META_DEK_LEN]>,
18351    wal_dek: Option<&zeroize::Zeroizing<[u8; 32]>>,
18352    apply: bool,
18353    table_roots: Option<&HashMap<u64, Arc<crate::durable_file::DurableRoot>>>,
18354) -> Result<()> {
18355    use crate::wal::SharedWal;
18356    let records = match durable_root {
18357        Some(root) => SharedWal::replay_durable_with_dek(root, wal_dek)?,
18358        None => SharedWal::replay_with_dek(root, wal_dek)?,
18359    };
18360    recover_ddl_from_records(
18361        root,
18362        durable_root,
18363        target_catalog,
18364        meta_dek,
18365        apply,
18366        table_roots,
18367        &records,
18368    )
18369}
18370
18371fn recover_ddl_from_records(
18372    root: &Path,
18373    durable_root: Option<&crate::durable_file::DurableRoot>,
18374    target_catalog: &mut Catalog,
18375    meta_dek: Option<&[u8; META_DEK_LEN]>,
18376    apply: bool,
18377    table_roots: Option<&HashMap<u64, Arc<crate::durable_file::DurableRoot>>>,
18378    records: &[crate::wal::Record],
18379) -> Result<()> {
18380    use crate::wal::{DdlOp, Op};
18381
18382    let original_catalog = target_catalog.clone();
18383    let mut recovered_catalog = original_catalog.clone();
18384    let cat = &mut recovered_catalog;
18385    let mut created_table_ids = HashSet::<u64>::new();
18386    let mut ttl_updates = HashMap::<u64, (Option<crate::manifest::TtlPolicy>, u64)>::new();
18387
18388    let mut committed: HashMap<u64, u64> = HashMap::new();
18389    for r in records {
18390        if let Op::TxnCommit { epoch: ce, .. } = r.op {
18391            committed.insert(r.txn_id, ce);
18392        }
18393    }
18394    let catalog_snapshot_txns = records
18395        .iter()
18396        .filter_map(|record| {
18397            (committed.contains_key(&record.txn_id)
18398                && matches!(&record.op, Op::Ddl(DdlOp::CatalogSnapshot { .. })))
18399            .then_some(record.txn_id)
18400        })
18401        .collect::<HashSet<_>>();
18402
18403    let mut changed = false;
18404    let mut applied_catalog_epoch = cat.db_epoch;
18405    let max_committed_epoch = committed.values().copied().max().unwrap_or(cat.db_epoch);
18406    for r in records.iter().cloned() {
18407        let Some(&ce) = committed.get(&r.txn_id) else {
18408            continue;
18409        };
18410        let txn_id = r.txn_id;
18411        match r.op {
18412            Op::Ddl(DdlOp::CreateTable {
18413                table_id,
18414                ref name,
18415                ref schema_json,
18416            }) => {
18417                if cat.tables.iter().any(|t| t.table_id == table_id) {
18418                    continue;
18419                }
18420                let schema = DdlOp::decode_schema(schema_json)?;
18421                validate_recovered_schema(&schema)?;
18422                created_table_ids.insert(table_id);
18423                cat.tables.push(CatalogEntry {
18424                    table_id,
18425                    name: name.clone(),
18426                    schema,
18427                    state: TableState::Live,
18428                    created_epoch: ce,
18429                });
18430                cat.next_table_id =
18431                    cat.next_table_id
18432                        .max(table_id.checked_add(1).ok_or_else(|| {
18433                            MongrelError::Full("table id namespace exhausted".into())
18434                        })?);
18435                changed = true;
18436            }
18437            Op::Ddl(DdlOp::CreateBuildingTable {
18438                table_id,
18439                ref build_name,
18440                ref intended_name,
18441                ref query_id,
18442                created_at_unix_nanos,
18443                ref schema_json,
18444            }) => {
18445                if cat.tables.iter().any(|table| table.table_id == table_id) {
18446                    continue;
18447                }
18448                let schema = DdlOp::decode_schema(schema_json)?;
18449                validate_recovered_schema(&schema)?;
18450                created_table_ids.insert(table_id);
18451                cat.tables.push(CatalogEntry {
18452                    table_id,
18453                    name: build_name.clone(),
18454                    schema,
18455                    state: TableState::Building {
18456                        intended_name: intended_name.clone(),
18457                        query_id: query_id.clone(),
18458                        created_at_unix_nanos,
18459                        replaces_table_id: None,
18460                    },
18461                    created_epoch: ce,
18462                });
18463                cat.next_table_id =
18464                    cat.next_table_id
18465                        .max(table_id.checked_add(1).ok_or_else(|| {
18466                            MongrelError::Full("table id namespace exhausted".into())
18467                        })?);
18468                changed = true;
18469            }
18470            Op::Ddl(DdlOp::CreateRebuildingTable {
18471                table_id,
18472                ref build_name,
18473                ref intended_name,
18474                ref query_id,
18475                created_at_unix_nanos,
18476                replaces_table_id,
18477                ref schema_json,
18478            }) => {
18479                if cat.tables.iter().any(|table| table.table_id == table_id) {
18480                    continue;
18481                }
18482                let schema = DdlOp::decode_schema(schema_json)?;
18483                validate_recovered_schema(&schema)?;
18484                created_table_ids.insert(table_id);
18485                cat.tables.push(CatalogEntry {
18486                    table_id,
18487                    name: build_name.clone(),
18488                    schema,
18489                    state: TableState::Building {
18490                        intended_name: intended_name.clone(),
18491                        query_id: query_id.clone(),
18492                        created_at_unix_nanos,
18493                        replaces_table_id: Some(replaces_table_id),
18494                    },
18495                    created_epoch: ce,
18496                });
18497                cat.next_table_id =
18498                    cat.next_table_id
18499                        .max(table_id.checked_add(1).ok_or_else(|| {
18500                            MongrelError::Full("table id namespace exhausted".into())
18501                        })?);
18502                changed = true;
18503            }
18504            Op::Ddl(DdlOp::DropTable { table_id }) => {
18505                let mut dropped_name = None;
18506                if let Some(entry) = cat.tables.iter_mut().find(|t| t.table_id == table_id) {
18507                    if matches!(entry.state, TableState::Live | TableState::Building { .. }) {
18508                        dropped_name = Some(entry.name.clone());
18509                        entry.state = TableState::Dropped { at_epoch: ce };
18510                        changed = true;
18511                    }
18512                }
18513                if let Some(name) = dropped_name {
18514                    let before = cat.materialized_views.len();
18515                    cat.materialized_views
18516                        .retain(|definition| definition.name != name);
18517                    changed |= before != cat.materialized_views.len();
18518                    cat.security.rls_tables.retain(|table| table != &name);
18519                    cat.security.policies.retain(|policy| policy.table != name);
18520                    cat.security.masks.retain(|mask| mask.table != name);
18521                    for role in &mut cat.roles {
18522                        role.permissions
18523                            .retain(|permission| permission_table(permission) != Some(&name));
18524                    }
18525                    if !catalog_snapshot_txns.contains(&txn_id) {
18526                        advance_security_version(cat)?;
18527                    }
18528                }
18529            }
18530            Op::Ddl(DdlOp::PublishBuildingTable {
18531                table_id,
18532                ref new_name,
18533            }) => {
18534                if let Some(entry) = cat
18535                    .tables
18536                    .iter_mut()
18537                    .find(|table| table.table_id == table_id)
18538                {
18539                    if entry.name != *new_name || !matches!(entry.state, TableState::Live) {
18540                        entry.name = new_name.clone();
18541                        entry.state = TableState::Live;
18542                        changed = true;
18543                    }
18544                }
18545            }
18546            Op::Ddl(DdlOp::ReplaceBuildingTable {
18547                table_id,
18548                replaced_table_id,
18549                ref new_name,
18550            }) => {
18551                changed |=
18552                    apply_rebuilding_publish(cat, table_id, replaced_table_id, new_name, ce)?;
18553            }
18554            Op::Ddl(DdlOp::RenameTable {
18555                table_id,
18556                ref new_name,
18557            }) => {
18558                let mut old_name = None;
18559                if let Some(entry) = cat.tables.iter_mut().find(|t| t.table_id == table_id) {
18560                    if entry.name != *new_name {
18561                        old_name = Some(entry.name.clone());
18562                        entry.name = new_name.clone();
18563                        changed = true;
18564                    }
18565                }
18566                if let Some(old_name) = old_name {
18567                    if let Some(definition) = cat
18568                        .materialized_views
18569                        .iter_mut()
18570                        .find(|definition| definition.name == old_name)
18571                    {
18572                        definition.name = new_name.clone();
18573                    }
18574                    for table in &mut cat.security.rls_tables {
18575                        if *table == old_name {
18576                            *table = new_name.clone();
18577                        }
18578                    }
18579                    for policy in &mut cat.security.policies {
18580                        if policy.table == old_name {
18581                            policy.table = new_name.clone();
18582                        }
18583                    }
18584                    for mask in &mut cat.security.masks {
18585                        if mask.table == old_name {
18586                            mask.table = new_name.clone();
18587                        }
18588                    }
18589                    for role in &mut cat.roles {
18590                        for permission in &mut role.permissions {
18591                            rename_permission_table(permission, &old_name, new_name);
18592                        }
18593                    }
18594                    if !catalog_snapshot_txns.contains(&txn_id) {
18595                        advance_security_version(cat)?;
18596                    }
18597                }
18598                // If the entry is absent, its CreateTable was already
18599                // checkpointed carrying the post-rename name, so there is
18600                // nothing to apply — a no-op, not an error.
18601            }
18602            Op::Ddl(DdlOp::AlterTable {
18603                table_id,
18604                ref column_json,
18605            }) => {
18606                let column = DdlOp::decode_column(column_json)?;
18607                let mut renamed = None;
18608                if let Some(entry) = cat.tables.iter_mut().find(|t| t.table_id == table_id) {
18609                    renamed = entry
18610                        .schema
18611                        .columns
18612                        .iter()
18613                        .find(|existing| existing.id == column.id && existing.name != column.name)
18614                        .map(|existing| {
18615                            (
18616                                entry.name.clone(),
18617                                existing.name.clone(),
18618                                column.name.clone(),
18619                            )
18620                        });
18621                    if apply_recovered_column_def(&mut entry.schema, column)? {
18622                        validate_recovered_schema(&entry.schema)?;
18623                        changed = true;
18624                    }
18625                }
18626                if let Some((table, old_name, new_name)) = renamed {
18627                    for role in &mut cat.roles {
18628                        for permission in &mut role.permissions {
18629                            rename_permission_column(permission, &table, &old_name, &new_name);
18630                        }
18631                    }
18632                    if !catalog_snapshot_txns.contains(&txn_id) {
18633                        advance_security_version(cat)?;
18634                    }
18635                }
18636            }
18637            Op::Ddl(DdlOp::SetTtl {
18638                table_id,
18639                ref policy_json,
18640            }) => {
18641                let policy = DdlOp::decode_ttl(policy_json)?;
18642                let entry = cat
18643                    .tables
18644                    .iter()
18645                    .find(|entry| entry.table_id == table_id)
18646                    .ok_or_else(|| {
18647                        MongrelError::Schema(format!(
18648                            "recovered TTL references unknown table id {table_id}"
18649                        ))
18650                    })?;
18651                if let Some(policy) = policy {
18652                    let valid = entry
18653                        .schema
18654                        .columns
18655                        .iter()
18656                        .find(|column| column.id == policy.column_id)
18657                        .is_some_and(|column| {
18658                            column.ty == TypeId::TimestampNanos
18659                                && policy.duration_nanos > 0
18660                                && policy.duration_nanos <= i64::MAX as u64
18661                        });
18662                    if !valid {
18663                        return Err(MongrelError::Schema(format!(
18664                            "invalid recovered TTL policy for table id {table_id}"
18665                        )));
18666                    }
18667                }
18668                ttl_updates.insert(table_id, (policy, ce));
18669            }
18670            Op::Ddl(DdlOp::SetMaterializedView {
18671                ref name,
18672                ref definition_json,
18673            }) => {
18674                let definition = DdlOp::decode_materialized_view(definition_json)?;
18675                if definition.name != *name {
18676                    return Err(MongrelError::Schema(format!(
18677                        "materialized view WAL name mismatch: {name:?}"
18678                    )));
18679                }
18680                if cat.live(name).is_some() {
18681                    if let Some(existing) = cat
18682                        .materialized_views
18683                        .iter_mut()
18684                        .find(|existing| existing.name == *name)
18685                    {
18686                        if *existing != definition {
18687                            *existing = definition;
18688                            changed = true;
18689                        }
18690                    } else {
18691                        cat.materialized_views.push(definition);
18692                        changed = true;
18693                    }
18694                }
18695            }
18696            Op::Ddl(DdlOp::SetSecurityCatalog { ref security_json }) => {
18697                let security = DdlOp::decode_security(security_json)?;
18698                validate_security_catalog(cat, &security)?;
18699                if cat.security != security {
18700                    cat.security = security;
18701                    if !catalog_snapshot_txns.contains(&txn_id) {
18702                        advance_security_version(cat)?;
18703                    }
18704                    changed = true;
18705                }
18706            }
18707            Op::Ddl(DdlOp::SetSqlPragma { ref key, value }) => {
18708                let target = match key.as_str() {
18709                    "user_version" => &mut cat.user_version,
18710                    "application_id" => &mut cat.application_id,
18711                    _ => {
18712                        return Err(MongrelError::InvalidArgument(format!(
18713                            "unsupported recovered SQL pragma {key:?}"
18714                        )))
18715                    }
18716                };
18717                if *target != Some(value) {
18718                    *target = Some(value);
18719                    cat.db_epoch = cat.db_epoch.max(ce);
18720                    changed = true;
18721                }
18722            }
18723            Op::Ddl(DdlOp::CatalogSnapshot { ref catalog_json }) => {
18724                if ce <= applied_catalog_epoch {
18725                    continue;
18726                }
18727                let snapshot = DdlOp::decode_catalog(catalog_json)?;
18728                if snapshot.db_epoch != ce {
18729                    return Err(MongrelError::Schema(format!(
18730                        "catalog snapshot epoch {} does not match WAL commit epoch {ce}",
18731                        snapshot.db_epoch
18732                    )));
18733                }
18734                validate_recovered_catalog(&snapshot)?;
18735                validate_catalog_transition(cat, &snapshot)?;
18736                *cat = snapshot;
18737                applied_catalog_epoch = ce;
18738                changed = true;
18739            }
18740            _ => {}
18741        }
18742    }
18743
18744    if cat.db_epoch < max_committed_epoch {
18745        cat.db_epoch = max_committed_epoch;
18746        changed = true;
18747    }
18748    changed |= repair_catalog_allocator_counters(cat)?;
18749
18750    validate_recovered_catalog(cat)?;
18751    let storage_reconciliation = validate_recovered_storage_plan(
18752        root,
18753        durable_root,
18754        cat,
18755        &created_table_ids,
18756        &ttl_updates,
18757        meta_dek,
18758    )?;
18759
18760    let needs_storage_apply = !storage_reconciliation.is_empty() || !ttl_updates.is_empty();
18761    if apply && (changed || needs_storage_apply) {
18762        for table_id in storage_reconciliation {
18763            let entry = cat
18764                .tables
18765                .iter()
18766                .find(|entry| entry.table_id == table_id)
18767                .ok_or_else(|| MongrelError::CorruptWal {
18768                    offset: table_id,
18769                    reason: "recovery storage plan lost its catalog table".into(),
18770                })?;
18771            ensure_recovered_table_storage(
18772                table_roots
18773                    .and_then(|roots| roots.get(&table_id))
18774                    .map(Arc::as_ref),
18775                durable_root,
18776                &root.join(TABLES_DIR).join(table_id.to_string()),
18777                table_id,
18778                &entry.schema,
18779                meta_dek,
18780            )?;
18781        }
18782        for (table_id, (policy, ttl_epoch)) in ttl_updates {
18783            let Some(entry) = cat.tables.iter().find(|entry| {
18784                entry.table_id == table_id
18785                    && matches!(entry.state, TableState::Live | TableState::Building { .. })
18786            }) else {
18787                continue;
18788            };
18789            let table_root = if let Some(root) = table_roots.and_then(|roots| roots.get(&table_id))
18790            {
18791                root.try_clone()?
18792            } else if let Some(root) = durable_root {
18793                root.open_directory(Path::new(TABLES_DIR).join(table_id.to_string()))?
18794            } else {
18795                crate::durable_file::DurableRoot::open(
18796                    root.join(TABLES_DIR).join(table_id.to_string()),
18797                )?
18798            };
18799            let table_dir = table_root.io_path()?;
18800            let mut manifest = crate::manifest::read_durable(&table_root, "", meta_dek)?;
18801            if manifest.ttl != policy || manifest.current_epoch < ttl_epoch {
18802                manifest.ttl = policy;
18803                manifest.current_epoch = manifest.current_epoch.max(ttl_epoch);
18804                manifest.schema_id = entry.schema.schema_id;
18805                crate::manifest::write_atomic(&table_dir, &mut manifest, meta_dek)?;
18806            }
18807        }
18808        if changed {
18809            match durable_root {
18810                Some(root) => catalog::write_durable(root, cat, meta_dek)?,
18811                None => catalog::write_atomic(root, cat, meta_dek)?,
18812            }
18813        }
18814    }
18815    *target_catalog = recovered_catalog;
18816    Ok(())
18817}
18818
18819fn ensure_recovered_table_storage(
18820    pinned_table: Option<&crate::durable_file::DurableRoot>,
18821    durable_root: Option<&crate::durable_file::DurableRoot>,
18822    fallback_table_dir: &Path,
18823    table_id: u64,
18824    schema: &Schema,
18825    meta_dek: Option<&[u8; META_DEK_LEN]>,
18826) -> Result<()> {
18827    let table_root = if let Some(root) = pinned_table {
18828        root.try_clone()?
18829    } else if let Some(root) = durable_root {
18830        let relative = Path::new(TABLES_DIR).join(table_id.to_string());
18831        match root.open_directory(&relative) {
18832            Ok(table) => table,
18833            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
18834                root.create_directory_all_pinned(relative)?
18835            }
18836            Err(error) => return Err(error.into()),
18837        }
18838    } else {
18839        crate::durable_file::create_directory_all(fallback_table_dir)?;
18840        crate::durable_file::DurableRoot::open(fallback_table_dir)?
18841    };
18842    let table_dir = table_root.io_path()?;
18843    let mut existing_manifest = match crate::manifest::read_durable(&table_root, "", meta_dek) {
18844        Ok(manifest) => {
18845            if manifest.table_id != table_id {
18846                return Err(MongrelError::Conflict(format!(
18847                    "recovered table directory id mismatch: expected {table_id}, found {}",
18848                    manifest.table_id
18849                )));
18850            }
18851            Some(manifest)
18852        }
18853        Err(MongrelError::Io(error)) if error.kind() == std::io::ErrorKind::NotFound => None,
18854        Err(error) => return Err(error),
18855    };
18856
18857    table_root.create_directory_all(crate::engine::WAL_DIR)?;
18858    table_root.create_directory_all(crate::engine::RUNS_DIR)?;
18859    crate::engine::write_schema(&table_dir, schema)?;
18860
18861    if let Some(mut manifest) = existing_manifest.take() {
18862        if manifest.schema_id != schema.schema_id {
18863            manifest.schema_id = schema.schema_id;
18864            crate::manifest::write_atomic(&table_dir, &mut manifest, meta_dek)?;
18865        }
18866    } else {
18867        // The DB-wide meta DEK is also the per-table manifest meta DEK.
18868        let mut manifest = crate::manifest::Manifest::new(table_id, schema.schema_id);
18869        crate::manifest::write_atomic(&table_dir, &mut manifest, meta_dek)?;
18870    }
18871    Ok(())
18872}
18873
18874fn validate_recovered_schema(schema: &Schema) -> Result<()> {
18875    schema.validate_auto_increment()?;
18876    schema.validate_defaults()?;
18877    schema.validate_ai()?;
18878    let mut column_ids = HashSet::new();
18879    let mut column_names = HashSet::new();
18880    for column in &schema.columns {
18881        if !column_ids.insert(column.id) || !column_names.insert(column.name.as_str()) {
18882            return Err(MongrelError::Schema(
18883                "recovered schema contains duplicate columns".into(),
18884            ));
18885        }
18886        match &column.ty {
18887            TypeId::Decimal128 { precision, scale }
18888                if *precision == 0 || *precision > 38 || scale.unsigned_abs() > *precision =>
18889            {
18890                return Err(MongrelError::Schema(format!(
18891                    "column {:?} has invalid decimal precision or scale",
18892                    column.name
18893                )));
18894            }
18895            TypeId::Enum { variants }
18896                if variants.is_empty()
18897                    || variants.iter().any(String::is_empty)
18898                    || variants.iter().collect::<HashSet<_>>().len() != variants.len() =>
18899            {
18900                return Err(MongrelError::Schema(format!(
18901                    "column {:?} has invalid enum variants",
18902                    column.name
18903                )));
18904            }
18905            _ => {}
18906        }
18907    }
18908    let mut index_names = HashSet::new();
18909    for index in &schema.indexes {
18910        index.validate_options()?;
18911        if index.name.is_empty()
18912            || !index_names.insert(index.name.as_str())
18913            || schema
18914                .columns
18915                .iter()
18916                .all(|column| column.id != index.column_id)
18917        {
18918            return Err(MongrelError::Schema(format!(
18919                "recovered index {:?} references missing column {}",
18920                index.name, index.column_id
18921            )));
18922        }
18923    }
18924    let mut colocated = HashSet::new();
18925    for group in &schema.colocation {
18926        if group.is_empty()
18927            || group.iter().any(|id| !column_ids.contains(id))
18928            || group.iter().any(|id| !colocated.insert(*id))
18929        {
18930            return Err(MongrelError::Schema(
18931                "recovered schema contains invalid column co-location groups".into(),
18932            ));
18933        }
18934    }
18935
18936    let mut constraint_ids = HashSet::new();
18937    let mut constraint_names = HashSet::<String>::new();
18938    let mut validate_constraint_identity = |id: u16, name: &str| -> Result<()> {
18939        if name.is_empty()
18940            || !constraint_ids.insert(id)
18941            || !constraint_names.insert(name.to_owned())
18942        {
18943            return Err(MongrelError::Schema(
18944                "recovered schema contains duplicate or empty constraint identities".into(),
18945            ));
18946        }
18947        Ok(())
18948    };
18949    for unique in &schema.constraints.uniques {
18950        validate_constraint_identity(unique.id, &unique.name)?;
18951        if unique.columns.is_empty()
18952            || unique.columns.iter().any(|id| !column_ids.contains(id))
18953            || unique.columns.iter().collect::<HashSet<_>>().len() != unique.columns.len()
18954        {
18955            return Err(MongrelError::Schema(format!(
18956                "unique constraint {:?} has invalid columns",
18957                unique.name
18958            )));
18959        }
18960    }
18961    for foreign_key in &schema.constraints.foreign_keys {
18962        validate_constraint_identity(foreign_key.id, &foreign_key.name)?;
18963        if foreign_key.ref_table.is_empty()
18964            || foreign_key.columns.is_empty()
18965            || foreign_key.columns.len() != foreign_key.ref_columns.len()
18966            || foreign_key
18967                .columns
18968                .iter()
18969                .any(|id| !column_ids.contains(id))
18970            || foreign_key.columns.iter().collect::<HashSet<_>>().len() != foreign_key.columns.len()
18971            || foreign_key.ref_columns.iter().collect::<HashSet<_>>().len()
18972                != foreign_key.ref_columns.len()
18973        {
18974            return Err(MongrelError::Schema(format!(
18975                "foreign key {:?} has invalid columns",
18976                foreign_key.name
18977            )));
18978        }
18979        if (matches!(foreign_key.on_delete, crate::constraint::FkAction::SetNull)
18980            || matches!(foreign_key.on_update, crate::constraint::FkAction::SetNull))
18981            && foreign_key.columns.iter().any(|id| {
18982                schema
18983                    .columns
18984                    .iter()
18985                    .find(|column| column.id == *id)
18986                    .is_none_or(|column| {
18987                        !column.flags.contains(crate::schema::ColumnFlags::NULLABLE)
18988                    })
18989            })
18990        {
18991            return Err(MongrelError::Schema(format!(
18992                "foreign key {:?} uses SET NULL on a non-nullable column",
18993                foreign_key.name
18994            )));
18995        }
18996    }
18997    for check in &schema.constraints.checks {
18998        validate_constraint_identity(check.id, &check.name)?;
18999        check.expr.validate()?;
19000        validate_check_columns(&check.expr, &column_ids)?;
19001    }
19002    Ok(())
19003}
19004
19005fn validate_check_columns(
19006    expression: &crate::constraint::CheckExpr,
19007    column_ids: &HashSet<u16>,
19008) -> Result<()> {
19009    use crate::constraint::CheckExpr;
19010    match expression {
19011        CheckExpr::Col(id) | CheckExpr::IsNull(id) | CheckExpr::IsNotNull(id) => {
19012            if column_ids.contains(id) {
19013                Ok(())
19014            } else {
19015                Err(MongrelError::Schema(format!(
19016                    "check constraint references unknown column {id}"
19017                )))
19018            }
19019        }
19020        CheckExpr::Regex { col, .. } => {
19021            if column_ids.contains(col) {
19022                Ok(())
19023            } else {
19024                Err(MongrelError::Schema(format!(
19025                    "check constraint references unknown column {col}"
19026                )))
19027            }
19028        }
19029        CheckExpr::Add(left, right)
19030        | CheckExpr::Sub(left, right)
19031        | CheckExpr::Mul(left, right)
19032        | CheckExpr::Div(left, right)
19033        | CheckExpr::Mod(left, right)
19034        | CheckExpr::Eq(left, right)
19035        | CheckExpr::Ne(left, right)
19036        | CheckExpr::Lt(left, right)
19037        | CheckExpr::Le(left, right)
19038        | CheckExpr::Gt(left, right)
19039        | CheckExpr::Ge(left, right)
19040        | CheckExpr::And(left, right)
19041        | CheckExpr::Or(left, right) => {
19042            validate_check_columns(left, column_ids)?;
19043            validate_check_columns(right, column_ids)
19044        }
19045        CheckExpr::Not(inner) => validate_check_columns(inner, column_ids),
19046        CheckExpr::True | CheckExpr::Lit(_) => Ok(()),
19047    }
19048}
19049
19050fn validate_catalog_transition(current: &Catalog, next: &Catalog) -> Result<()> {
19051    for (name, prior, candidate) in [
19052        ("db_epoch", current.db_epoch, next.db_epoch),
19053        ("next_table_id", current.next_table_id, next.next_table_id),
19054        (
19055            "next_segment_no",
19056            current.next_segment_no,
19057            next.next_segment_no,
19058        ),
19059        ("next_user_id", current.next_user_id, next.next_user_id),
19060        (
19061            "security_version",
19062            current.security_version,
19063            next.security_version,
19064        ),
19065    ] {
19066        if candidate < prior {
19067            return Err(MongrelError::Schema(format!(
19068                "catalog snapshot rolls back {name} from {prior} to {candidate}"
19069            )));
19070        }
19071    }
19072    for prior in &current.tables {
19073        let Some(candidate) = next
19074            .tables
19075            .iter()
19076            .find(|entry| entry.table_id == prior.table_id)
19077        else {
19078            return Err(MongrelError::Schema(format!(
19079                "catalog snapshot removes table identity {}",
19080                prior.table_id
19081            )));
19082        };
19083        if candidate.created_epoch != prior.created_epoch
19084            || candidate.schema.schema_id < prior.schema.schema_id
19085            || matches!(prior.state, TableState::Dropped { .. })
19086                && !matches!(candidate.state, TableState::Dropped { .. })
19087        {
19088            return Err(MongrelError::Schema(format!(
19089                "catalog snapshot rolls back table identity {}",
19090                prior.table_id
19091            )));
19092        }
19093    }
19094    for prior in &current.users {
19095        if let Some(candidate) = next.users.iter().find(|user| user.id == prior.id) {
19096            if candidate.username != prior.username
19097                || candidate.created_epoch != prior.created_epoch
19098            {
19099                return Err(MongrelError::Schema(format!(
19100                    "catalog snapshot reuses user identity {}",
19101                    prior.id
19102                )));
19103            }
19104        }
19105    }
19106    Ok(())
19107}
19108
19109fn validate_recovered_catalog(catalog: &Catalog) -> Result<()> {
19110    let mut table_ids = HashSet::new();
19111    let mut active_names = HashSet::new();
19112    let mut max_table_id = None::<u64>;
19113    for entry in &catalog.tables {
19114        if !table_ids.insert(entry.table_id) {
19115            return Err(MongrelError::Schema(format!(
19116                "catalog contains duplicate table id {}",
19117                entry.table_id
19118            )));
19119        }
19120        max_table_id = Some(max_table_id.map_or(entry.table_id, |value| value.max(entry.table_id)));
19121        if entry.name.is_empty() || entry.created_epoch > catalog.db_epoch {
19122            return Err(MongrelError::Schema(format!(
19123                "catalog table {} has invalid name or creation epoch",
19124                entry.table_id
19125            )));
19126        }
19127        validate_recovered_schema(&entry.schema)?;
19128        match &entry.state {
19129            TableState::Live => {
19130                if !active_names.insert(entry.name.as_str()) {
19131                    return Err(MongrelError::Schema(format!(
19132                        "catalog contains duplicate active table name {:?}",
19133                        entry.name
19134                    )));
19135                }
19136            }
19137            TableState::Dropped { at_epoch } => {
19138                if *at_epoch < entry.created_epoch || *at_epoch > catalog.db_epoch {
19139                    return Err(MongrelError::Schema(format!(
19140                        "catalog table {} has invalid drop epoch {at_epoch}",
19141                        entry.table_id
19142                    )));
19143                }
19144            }
19145            TableState::Building {
19146                intended_name,
19147                query_id,
19148                replaces_table_id,
19149                ..
19150            } => {
19151                if intended_name.is_empty() || query_id.is_empty() {
19152                    return Err(MongrelError::Schema(format!(
19153                        "building table {} has empty identity fields",
19154                        entry.table_id
19155                    )));
19156                }
19157                if !active_names.insert(entry.name.as_str()) {
19158                    return Err(MongrelError::Schema(format!(
19159                        "catalog contains duplicate active/building table name {:?}",
19160                        entry.name
19161                    )));
19162                }
19163                if replaces_table_id.is_some_and(|id| id == entry.table_id) {
19164                    return Err(MongrelError::Schema(
19165                        "building table cannot replace itself".into(),
19166                    ));
19167                }
19168            }
19169        }
19170    }
19171    if let Some(maximum) = max_table_id {
19172        let required = maximum
19173            .checked_add(1)
19174            .ok_or_else(|| MongrelError::Full("table id namespace exhausted".into()))?;
19175        if catalog.next_table_id < required {
19176            return Err(MongrelError::Schema(format!(
19177                "catalog next_table_id {} precedes required {required}",
19178                catalog.next_table_id
19179            )));
19180        }
19181    }
19182    for entry in &catalog.tables {
19183        if let TableState::Building {
19184            replaces_table_id: Some(replaced),
19185            ..
19186        } = entry.state
19187        {
19188            if !table_ids.contains(&replaced) {
19189                return Err(MongrelError::Schema(format!(
19190                    "building table {} replaces unknown table {replaced}",
19191                    entry.table_id
19192                )));
19193            }
19194        }
19195    }
19196    for entry in &catalog.tables {
19197        if matches!(entry.state, TableState::Live | TableState::Building { .. }) {
19198            validate_foreign_key_targets(catalog, &entry.schema)?;
19199        }
19200    }
19201
19202    let mut external_names = HashSet::new();
19203    for entry in &catalog.external_tables {
19204        entry.validate()?;
19205        validate_recovered_schema(&entry.declared_schema)?;
19206        if !entry.declared_schema.constraints.is_empty() {
19207            return Err(MongrelError::Schema(format!(
19208                "external table {:?} cannot carry engine-enforced constraints",
19209                entry.name
19210            )));
19211        }
19212        if entry.created_epoch > catalog.db_epoch
19213            || !external_names.insert(entry.name.as_str())
19214            || active_names.contains(entry.name.as_str())
19215        {
19216            return Err(MongrelError::Schema(format!(
19217                "invalid or duplicate external table {:?}",
19218                entry.name
19219            )));
19220        }
19221    }
19222
19223    let mut procedure_names = HashSet::new();
19224    for entry in &catalog.procedures {
19225        entry.procedure.validate()?;
19226        if entry.procedure.created_epoch > entry.procedure.updated_epoch
19227            || entry.procedure.updated_epoch > catalog.db_epoch
19228            || !procedure_names.insert(entry.procedure.name.as_str())
19229        {
19230            return Err(MongrelError::Schema(format!(
19231                "invalid or duplicate procedure {:?}",
19232                entry.procedure.name
19233            )));
19234        }
19235        validate_recovered_procedure_references(catalog, &entry.procedure)?;
19236    }
19237
19238    let mut trigger_names = HashSet::new();
19239    for entry in &catalog.triggers {
19240        entry.trigger.validate()?;
19241        if entry.trigger.created_epoch > entry.trigger.updated_epoch
19242            || entry.trigger.updated_epoch > catalog.db_epoch
19243            || !trigger_names.insert(entry.trigger.name.as_str())
19244        {
19245            return Err(MongrelError::Schema(format!(
19246                "invalid or duplicate trigger {:?}",
19247                entry.trigger.name
19248            )));
19249        }
19250        validate_recovered_trigger_references(catalog, &entry.trigger)?;
19251    }
19252
19253    let mut views = HashSet::new();
19254    for view in &catalog.materialized_views {
19255        let target = catalog.live(&view.name).ok_or_else(|| {
19256            MongrelError::Schema(format!(
19257                "materialized view {:?} has no live table",
19258                view.name
19259            ))
19260        })?;
19261        if view.name.is_empty()
19262            || view.query.trim().is_empty()
19263            || view.last_refresh_epoch > catalog.db_epoch
19264            || !views.insert(view.name.as_str())
19265        {
19266            return Err(MongrelError::Schema(format!(
19267                "materialized view {:?} has no unique live table",
19268                view.name
19269            )));
19270        }
19271        if let Some(incremental) = &view.incremental {
19272            let source = catalog.live(&incremental.source_table).ok_or_else(|| {
19273                MongrelError::Schema(format!(
19274                    "materialized view {:?} references missing source {:?}",
19275                    view.name, incremental.source_table
19276                ))
19277            })?;
19278            if source.table_id != incremental.source_table_id
19279                || source
19280                    .schema
19281                    .columns
19282                    .iter()
19283                    .all(|column| column.id != incremental.group_column)
19284            {
19285                return Err(MongrelError::Schema(format!(
19286                    "materialized view {:?} has invalid incremental source",
19287                    view.name
19288                )));
19289            }
19290            let target_ids = target
19291                .schema
19292                .columns
19293                .iter()
19294                .map(|column| column.id)
19295                .collect::<HashSet<_>>();
19296            let mut output_ids = HashSet::new();
19297            let count_outputs = incremental
19298                .outputs
19299                .iter()
19300                .filter(|output| {
19301                    matches!(output.kind, crate::catalog::IncrementalAggregateKind::Count)
19302                })
19303                .count();
19304            if incremental.checkpoint_event_id.is_empty()
19305                || !target_ids.contains(&incremental.group_output_column)
19306                || !target_ids.contains(&incremental.count_output_column)
19307                || incremental.outputs.is_empty()
19308                || count_outputs != 1
19309                || incremental.outputs.iter().any(|output| {
19310                    !target_ids.contains(&output.output_column)
19311                        || output.output_column == incremental.group_output_column
19312                        || !output_ids.insert(output.output_column)
19313                        || matches!(output.kind, crate::catalog::IncrementalAggregateKind::Count)
19314                            && output.output_column != incremental.count_output_column
19315                        || match output.kind {
19316                            crate::catalog::IncrementalAggregateKind::Sum { source_column } => {
19317                                source
19318                                    .schema
19319                                    .columns
19320                                    .iter()
19321                                    .all(|column| column.id != source_column)
19322                            }
19323                            crate::catalog::IncrementalAggregateKind::Count => false,
19324                        }
19325                })
19326            {
19327                return Err(MongrelError::Schema(format!(
19328                    "materialized view {:?} has invalid incremental outputs",
19329                    view.name
19330                )));
19331            }
19332        }
19333    }
19334
19335    validate_security_catalog(catalog, &catalog.security)?;
19336    validate_recovered_auth_catalog(catalog)?;
19337    Ok(())
19338}
19339
19340fn repair_catalog_allocator_counters(catalog: &mut Catalog) -> Result<bool> {
19341    let mut changed = false;
19342    if let Some(maximum) = catalog.tables.iter().map(|entry| entry.table_id).max() {
19343        let required = maximum
19344            .checked_add(1)
19345            .ok_or_else(|| MongrelError::Full("table id namespace exhausted".into()))?;
19346        if catalog.next_table_id < required {
19347            catalog.next_table_id = required;
19348            changed = true;
19349        }
19350    }
19351    if let Some(maximum) = catalog.users.iter().map(|user| user.id).max() {
19352        let required = maximum
19353            .checked_add(1)
19354            .ok_or_else(|| MongrelError::Full("user id namespace exhausted".into()))?;
19355        if catalog.next_user_id < required {
19356            catalog.next_user_id = required;
19357            changed = true;
19358        }
19359    }
19360    Ok(changed)
19361}
19362
19363fn validate_foreign_key_targets(catalog: &Catalog, schema: &Schema) -> Result<()> {
19364    for foreign_key in &schema.constraints.foreign_keys {
19365        let parent = catalog.live(&foreign_key.ref_table).ok_or_else(|| {
19366            MongrelError::Schema(format!(
19367                "foreign key {:?} references unknown live table {:?}",
19368                foreign_key.name, foreign_key.ref_table
19369            ))
19370        })?;
19371        let referenced_unique = parent
19372            .schema
19373            .constraints
19374            .uniques
19375            .iter()
19376            .any(|unique| unique.columns == foreign_key.ref_columns)
19377            || foreign_key.ref_columns.len() == 1
19378                && parent
19379                    .schema
19380                    .primary_key()
19381                    .is_some_and(|column| column.id == foreign_key.ref_columns[0]);
19382        if !referenced_unique {
19383            return Err(MongrelError::Schema(format!(
19384                "foreign key {:?} does not reference a unique key",
19385                foreign_key.name
19386            )));
19387        }
19388        for (local_id, parent_id) in foreign_key.columns.iter().zip(&foreign_key.ref_columns) {
19389            let local = schema.columns.iter().find(|column| column.id == *local_id);
19390            let referenced = parent
19391                .schema
19392                .columns
19393                .iter()
19394                .find(|column| column.id == *parent_id);
19395            if local
19396                .zip(referenced)
19397                .is_none_or(|(local, referenced)| local.ty != referenced.ty)
19398            {
19399                return Err(MongrelError::Schema(format!(
19400                    "foreign key {:?} has missing or incompatible columns",
19401                    foreign_key.name
19402                )));
19403            }
19404        }
19405    }
19406    Ok(())
19407}
19408
19409fn validate_recovered_procedure_references(
19410    catalog: &Catalog,
19411    procedure: &StoredProcedure,
19412) -> Result<()> {
19413    for step in &procedure.body.steps {
19414        let Some(table_name) = step.table() else {
19415            continue;
19416        };
19417        let schema = &catalog
19418            .live(table_name)
19419            .ok_or_else(|| {
19420                MongrelError::Schema(format!(
19421                    "procedure {:?} references unknown table {table_name:?}",
19422                    procedure.name
19423                ))
19424            })?
19425            .schema;
19426        match step {
19427            ProcedureStep::NativeQuery {
19428                conditions,
19429                projection,
19430                ..
19431            } => {
19432                for condition in conditions {
19433                    validate_condition_columns(condition, schema)?;
19434                }
19435                for id in projection.iter().flatten() {
19436                    validate_column_id(*id, schema)?;
19437                }
19438            }
19439            ProcedureStep::Put { cells, .. } => {
19440                for cell in cells {
19441                    validate_column_id(cell.column_id, schema)?;
19442                }
19443            }
19444            ProcedureStep::Upsert {
19445                cells,
19446                update_cells,
19447                ..
19448            } => {
19449                for cell in cells.iter().chain(update_cells.iter().flatten()) {
19450                    validate_column_id(cell.column_id, schema)?;
19451                }
19452            }
19453            ProcedureStep::DeleteByPk { .. } if schema.primary_key().is_none() => {
19454                return Err(MongrelError::Schema(format!(
19455                    "procedure {:?} deletes by primary key on table without one",
19456                    procedure.name
19457                )));
19458            }
19459            ProcedureStep::DeleteByPk { .. }
19460            | ProcedureStep::DeleteRows { .. }
19461            | ProcedureStep::SqlQuery { .. } => {}
19462        }
19463    }
19464    Ok(())
19465}
19466
19467fn validate_recovered_trigger_references(catalog: &Catalog, trigger: &StoredTrigger) -> Result<()> {
19468    let target_schema = match &trigger.target {
19469        TriggerTarget::Table(name) => catalog
19470            .live(name)
19471            .ok_or_else(|| {
19472                MongrelError::Schema(format!(
19473                    "trigger {:?} references unknown table {name:?}",
19474                    trigger.name
19475                ))
19476            })?
19477            .schema
19478            .clone(),
19479        TriggerTarget::View(_) => Schema {
19480            columns: trigger.target_columns.clone(),
19481            ..Schema::default()
19482        },
19483    };
19484    for column in &trigger.update_of {
19485        if target_schema.column(column).is_none() {
19486            return Err(MongrelError::Schema(format!(
19487                "trigger {:?} references unknown UPDATE OF column {column:?}",
19488                trigger.name
19489            )));
19490        }
19491    }
19492    if let Some(expr) = &trigger.when {
19493        validate_trigger_expr(expr, &target_schema, trigger.event)?;
19494    }
19495    let mut selects = HashMap::new();
19496    for step in &trigger.program.steps {
19497        if matches!(step, TriggerStep::SetNew { .. }) && trigger.timing != TriggerTiming::Before {
19498            return Err(MongrelError::Schema(
19499                "SetNew is only valid in BEFORE triggers".into(),
19500            ));
19501        }
19502        validate_trigger_step(step, catalog, &target_schema, trigger.event, &mut selects)?;
19503    }
19504    Ok(())
19505}
19506
19507fn validate_recovered_auth_catalog(catalog: &Catalog) -> Result<()> {
19508    let mut role_names = HashSet::new();
19509    for role in &catalog.roles {
19510        if role.name.is_empty()
19511            || role.created_epoch > catalog.db_epoch
19512            || !role_names.insert(role.name.as_str())
19513        {
19514            return Err(MongrelError::Schema(format!(
19515                "invalid or duplicate role {:?}",
19516                role.name
19517            )));
19518        }
19519        for permission in &role.permissions {
19520            if let Some(table) = permission_table(permission) {
19521                let schema = catalog
19522                    .live(table)
19523                    .map(|entry| &entry.schema)
19524                    .or_else(|| {
19525                        catalog
19526                            .external_tables
19527                            .iter()
19528                            .find(|entry| entry.name == table)
19529                            .map(|entry| &entry.declared_schema)
19530                    })
19531                    .ok_or_else(|| {
19532                        MongrelError::Schema(format!(
19533                            "role {:?} references unknown table {table:?}",
19534                            role.name
19535                        ))
19536                    })?;
19537                let columns = match permission {
19538                    crate::auth::Permission::SelectColumns { columns, .. }
19539                    | crate::auth::Permission::InsertColumns { columns, .. }
19540                    | crate::auth::Permission::UpdateColumns { columns, .. } => Some(columns),
19541                    _ => None,
19542                };
19543                if columns.is_some_and(|columns| {
19544                    columns.is_empty()
19545                        || columns.iter().any(|column| schema.column(column).is_none())
19546                }) {
19547                    return Err(MongrelError::Schema(format!(
19548                        "role {:?} contains invalid column permissions",
19549                        role.name
19550                    )));
19551                }
19552            }
19553        }
19554    }
19555    let mut user_ids = HashSet::new();
19556    let mut usernames = HashSet::new();
19557    let mut maximum_user_id = 0;
19558    for user in &catalog.users {
19559        maximum_user_id = maximum_user_id.max(user.id);
19560        if user.id == 0
19561            || user.username.is_empty()
19562            || user.password_hash.is_empty()
19563            || user.created_epoch > catalog.db_epoch
19564            || !user_ids.insert(user.id)
19565            || !usernames.insert(user.username.as_str())
19566            || user
19567                .roles
19568                .iter()
19569                .any(|role| !role_names.contains(role.as_str()))
19570        {
19571            return Err(MongrelError::Schema(format!(
19572                "invalid or duplicate user {:?}",
19573                user.username
19574            )));
19575        }
19576    }
19577    if !catalog.users.is_empty() && catalog.next_user_id <= maximum_user_id {
19578        return Err(MongrelError::Schema(
19579            "catalog next_user_id does not advance beyond existing user ids".into(),
19580        ));
19581    }
19582    if catalog.require_auth && !catalog.users.iter().any(|user| user.is_admin) {
19583        return Err(MongrelError::Schema(
19584            "authenticated catalog has no administrator".into(),
19585        ));
19586    }
19587    Ok(())
19588}
19589
19590fn validate_recovered_storage_plan(
19591    root: &Path,
19592    durable_root: Option<&crate::durable_file::DurableRoot>,
19593    catalog: &Catalog,
19594    created_table_ids: &HashSet<u64>,
19595    ttl_updates: &HashMap<u64, (Option<crate::manifest::TtlPolicy>, u64)>,
19596    meta_dek: Option<&[u8; META_DEK_LEN]>,
19597) -> Result<Vec<u64>> {
19598    const MAX_SCHEMA_BYTES: u64 = 16 * 1024 * 1024;
19599    let mut reconcile = Vec::new();
19600    for entry in &catalog.tables {
19601        if !matches!(entry.state, TableState::Live | TableState::Building { .. }) {
19602            continue;
19603        }
19604        let relative_dir = Path::new(TABLES_DIR).join(entry.table_id.to_string());
19605        let table_dir = root.join(TABLES_DIR).join(entry.table_id.to_string());
19606        let table_exists = match durable_root {
19607            Some(root) => match root.open_directory(&relative_dir) {
19608                Ok(_) => true,
19609                Err(error) if error.kind() == std::io::ErrorKind::NotFound => false,
19610                Err(error) => return Err(error.into()),
19611            },
19612            None => table_dir.is_dir(),
19613        };
19614        if !table_exists {
19615            if created_table_ids.contains(&entry.table_id) {
19616                reconcile.push(entry.table_id);
19617                continue;
19618            }
19619            return Err(MongrelError::NotFound(format!(
19620                "catalog table {} storage is missing",
19621                entry.table_id
19622            )));
19623        }
19624        let manifest_result = match durable_root {
19625            Some(root) => crate::manifest::read_durable(root, &relative_dir, meta_dek),
19626            None => crate::manifest::read(&table_dir, meta_dek),
19627        };
19628        let manifest = match manifest_result {
19629            Ok(manifest) => manifest,
19630            Err(MongrelError::Io(error))
19631                if created_table_ids.contains(&entry.table_id)
19632                    && error.kind() == std::io::ErrorKind::NotFound =>
19633            {
19634                reconcile.push(entry.table_id);
19635                continue;
19636            }
19637            Err(error) => return Err(error),
19638        };
19639        if manifest.table_id != entry.table_id {
19640            return Err(MongrelError::Conflict(format!(
19641                "catalog table {} storage identity mismatch",
19642                entry.table_id
19643            )));
19644        }
19645        let schema_result = match durable_root {
19646            Some(root) => root
19647                .open_regular(relative_dir.join(crate::engine::SCHEMA_FILENAME))
19648                .map_err(MongrelError::from),
19649            None => crate::durable_file::open_regular_nofollow(
19650                &table_dir.join(crate::engine::SCHEMA_FILENAME),
19651            ),
19652        };
19653        let file = match schema_result {
19654            Ok(file) => file,
19655            Err(MongrelError::Io(error))
19656                if created_table_ids.contains(&entry.table_id)
19657                    && error.kind() == std::io::ErrorKind::NotFound =>
19658            {
19659                reconcile.push(entry.table_id);
19660                continue;
19661            }
19662            Err(error) => return Err(error),
19663        };
19664        let length = file.metadata()?.len();
19665        if length > MAX_SCHEMA_BYTES {
19666            return Err(MongrelError::ResourceLimitExceeded {
19667                resource: "recovered schema bytes",
19668                requested: usize::try_from(length).unwrap_or(usize::MAX),
19669                limit: MAX_SCHEMA_BYTES as usize,
19670            });
19671        }
19672        let disk_schema: Schema = serde_json::from_reader(file.take(MAX_SCHEMA_BYTES + 1))
19673            .map_err(|error| MongrelError::Schema(format!("decode recovered schema: {error}")))?;
19674        if manifest.schema_id != entry.schema.schema_id
19675            || crate::wal::DdlOp::encode_schema(&disk_schema)?
19676                != crate::wal::DdlOp::encode_schema(&entry.schema)?
19677        {
19678            reconcile.push(entry.table_id);
19679        }
19680    }
19681    for table_id in ttl_updates.keys() {
19682        if !catalog.tables.iter().any(|entry| {
19683            entry.table_id == *table_id
19684                && matches!(entry.state, TableState::Live | TableState::Building { .. })
19685        }) {
19686            continue;
19687        }
19688        let relative_dir = Path::new(TABLES_DIR).join(table_id.to_string());
19689        let table_exists = match durable_root {
19690            Some(root) => match root.open_directory(&relative_dir) {
19691                Ok(_) => true,
19692                Err(error) if error.kind() == std::io::ErrorKind::NotFound => false,
19693                Err(error) => return Err(error.into()),
19694            },
19695            None => root.join(&relative_dir).is_dir(),
19696        };
19697        if !table_exists && !created_table_ids.contains(table_id) {
19698            return Err(MongrelError::NotFound(format!(
19699                "TTL recovery table {table_id} storage is missing"
19700            )));
19701        }
19702    }
19703    reconcile.sort_unstable();
19704    reconcile.dedup();
19705    Ok(reconcile)
19706}
19707
19708fn validate_catalog_table_storage(
19709    root: &crate::durable_file::DurableRoot,
19710    catalog: &Catalog,
19711    meta_dek: Option<&[u8; META_DEK_LEN]>,
19712) -> Result<()> {
19713    for entry in &catalog.tables {
19714        if !matches!(entry.state, TableState::Live | TableState::Building { .. }) {
19715            continue;
19716        }
19717        let table_dir = Path::new(TABLES_DIR).join(entry.table_id.to_string());
19718        let manifest = crate::manifest::read_durable(root, &table_dir, meta_dek)?;
19719        if manifest.table_id != entry.table_id || manifest.schema_id != entry.schema.schema_id {
19720            return Err(MongrelError::Conflict(format!(
19721                "catalog table {} storage identity mismatch",
19722                entry.table_id
19723            )));
19724        }
19725        root.open_regular(table_dir.join(crate::engine::SCHEMA_FILENAME))?;
19726    }
19727    Ok(())
19728}
19729
19730fn apply_recovered_column_def(schema: &mut Schema, column: ColumnDef) -> Result<bool> {
19731    match schema.columns.iter_mut().find(|c| c.id == column.id) {
19732        Some(existing) if *existing == column => Ok(false),
19733        Some(existing) => {
19734            *existing = column;
19735            schema.schema_id = schema
19736                .schema_id
19737                .checked_add(1)
19738                .ok_or_else(|| MongrelError::Schema("schema id space exhausted".into()))?;
19739            Ok(true)
19740        }
19741        None => {
19742            schema.columns.push(column);
19743            schema.schema_id = schema
19744                .schema_id
19745                .checked_add(1)
19746                .ok_or_else(|| MongrelError::Schema("schema id space exhausted".into()))?;
19747            Ok(true)
19748        }
19749    }
19750}
19751
19752fn permission_table(permission: &crate::auth::Permission) -> Option<&str> {
19753    use crate::auth::Permission;
19754    match permission {
19755        Permission::Select { table }
19756        | Permission::Insert { table }
19757        | Permission::Update { table }
19758        | Permission::Delete { table }
19759        | Permission::SelectColumns { table, .. }
19760        | Permission::InsertColumns { table, .. }
19761        | Permission::UpdateColumns { table, .. } => Some(table),
19762        Permission::All | Permission::Ddl | Permission::Admin => None,
19763    }
19764}
19765
19766fn apply_rebuilding_publish(
19767    catalog: &mut Catalog,
19768    table_id: u64,
19769    replaced_table_id: u64,
19770    new_name: &str,
19771    epoch: u64,
19772) -> Result<bool> {
19773    let already_published = catalog.tables.iter().any(|entry| {
19774        entry.table_id == table_id
19775            && entry.name == new_name
19776            && matches!(entry.state, TableState::Live)
19777    }) && catalog.tables.iter().any(|entry| {
19778        entry.table_id == replaced_table_id && matches!(entry.state, TableState::Dropped { .. })
19779    });
19780    if already_published {
19781        return Ok(false);
19782    }
19783    let schema = catalog
19784        .tables
19785        .iter()
19786        .find(|entry| entry.table_id == table_id)
19787        .ok_or_else(|| MongrelError::NotFound(format!("table id {table_id} not found")))?
19788        .schema
19789        .clone();
19790    let replaced = catalog
19791        .tables
19792        .iter_mut()
19793        .find(|entry| entry.table_id == replaced_table_id)
19794        .ok_or_else(|| MongrelError::NotFound(format!("table id {replaced_table_id} not found")))?;
19795    replaced.state = TableState::Dropped { at_epoch: epoch };
19796    let replacement = catalog
19797        .tables
19798        .iter_mut()
19799        .find(|entry| entry.table_id == table_id)
19800        .ok_or_else(|| MongrelError::NotFound(format!("table id {table_id} not found")))?;
19801    replacement.name = new_name.to_string();
19802    replacement.state = TableState::Live;
19803
19804    for role in &mut catalog.roles {
19805        role.permissions.retain_mut(|permission| {
19806            retain_rebuilt_permission_columns(permission, new_name, &schema)
19807        });
19808    }
19809    for definition in &mut catalog.materialized_views {
19810        if let Some(incremental) = definition.incremental.as_mut() {
19811            if incremental.source_table == new_name
19812                && incremental.source_table_id == replaced_table_id
19813            {
19814                incremental.source_table_id = table_id;
19815            }
19816        }
19817    }
19818    advance_security_version(catalog)?;
19819    Ok(true)
19820}
19821
19822fn retain_rebuilt_permission_columns(
19823    permission: &mut crate::auth::Permission,
19824    target_table: &str,
19825    schema: &Schema,
19826) -> bool {
19827    use crate::auth::Permission;
19828    let columns = match permission {
19829        Permission::SelectColumns { table, columns }
19830        | Permission::InsertColumns { table, columns }
19831        | Permission::UpdateColumns { table, columns }
19832            if table == target_table =>
19833        {
19834            Some(columns)
19835        }
19836        _ => None,
19837    };
19838    if let Some(columns) = columns {
19839        columns.retain(|column| schema.column(column).is_some());
19840        !columns.is_empty()
19841    } else {
19842        true
19843    }
19844}
19845
19846fn rename_permission_table(permission: &mut crate::auth::Permission, old: &str, new: &str) {
19847    use crate::auth::Permission;
19848    let table = match permission {
19849        Permission::Select { table }
19850        | Permission::Insert { table }
19851        | Permission::Update { table }
19852        | Permission::Delete { table }
19853        | Permission::SelectColumns { table, .. }
19854        | Permission::InsertColumns { table, .. }
19855        | Permission::UpdateColumns { table, .. } => Some(table),
19856        Permission::All | Permission::Ddl | Permission::Admin => None,
19857    };
19858    if let Some(table) = table.filter(|table| table.as_str() == old) {
19859        *table = new.to_string();
19860    }
19861}
19862
19863fn rename_permission_column(
19864    permission: &mut crate::auth::Permission,
19865    target_table: &str,
19866    old: &str,
19867    new: &str,
19868) {
19869    use crate::auth::Permission;
19870    let columns = match permission {
19871        Permission::SelectColumns { table, columns }
19872        | Permission::InsertColumns { table, columns }
19873        | Permission::UpdateColumns { table, columns }
19874            if table == target_table =>
19875        {
19876            Some(columns)
19877        }
19878        _ => None,
19879    };
19880    if let Some(column) = columns
19881        .into_iter()
19882        .flatten()
19883        .find(|column| column.as_str() == old)
19884    {
19885        *column = new.to_string();
19886    }
19887}
19888
19889pub(crate) fn validate_security_catalog(
19890    catalog: &Catalog,
19891    security: &crate::security::SecurityCatalog,
19892) -> Result<()> {
19893    let mut policy_names = HashSet::new();
19894    for table in &security.rls_tables {
19895        if catalog.live(table).is_none() {
19896            return Err(MongrelError::NotFound(format!(
19897                "RLS table {table:?} not found"
19898            )));
19899        }
19900    }
19901    for policy in &security.policies {
19902        if !policy_names.insert((policy.table.clone(), policy.name.clone())) {
19903            return Err(MongrelError::InvalidArgument(format!(
19904                "duplicate policy {:?} on {:?}",
19905                policy.name, policy.table
19906            )));
19907        }
19908        let schema = &catalog
19909            .live(&policy.table)
19910            .ok_or_else(|| {
19911                MongrelError::NotFound(format!("policy table {:?} not found", policy.table))
19912            })?
19913            .schema;
19914        if let Some(expression) = &policy.using {
19915            validate_security_expression(expression, schema)?;
19916        }
19917        if let Some(expression) = &policy.with_check {
19918            validate_security_expression(expression, schema)?;
19919        }
19920    }
19921    let mut mask_names = HashSet::new();
19922    for mask in &security.masks {
19923        if !mask_names.insert((mask.table.clone(), mask.name.clone())) {
19924            return Err(MongrelError::InvalidArgument(format!(
19925                "duplicate mask {:?} on {:?}",
19926                mask.name, mask.table
19927            )));
19928        }
19929        let column = catalog
19930            .live(&mask.table)
19931            .and_then(|entry| {
19932                entry
19933                    .schema
19934                    .columns
19935                    .iter()
19936                    .find(|column| column.id == mask.column)
19937            })
19938            .ok_or_else(|| {
19939                MongrelError::NotFound(format!(
19940                    "mask column {} on {:?} not found",
19941                    mask.column, mask.table
19942                ))
19943            })?;
19944        if matches!(
19945            mask.strategy,
19946            crate::security::MaskStrategy::Redact { .. } | crate::security::MaskStrategy::Sha256
19947        ) && !matches!(column.ty, TypeId::Bytes | TypeId::Enum { .. })
19948        {
19949            return Err(MongrelError::InvalidArgument(format!(
19950                "mask {:?} requires a string/bytes column",
19951                mask.name
19952            )));
19953        }
19954    }
19955    Ok(())
19956}
19957
19958fn validate_security_expression(
19959    expression: &crate::security::SecurityExpr,
19960    schema: &Schema,
19961) -> Result<()> {
19962    use crate::security::SecurityExpr;
19963    match expression {
19964        SecurityExpr::True => Ok(()),
19965        SecurityExpr::ColumnEqCurrentUser { column }
19966        | SecurityExpr::ColumnEqValue { column, .. } => {
19967            if schema
19968                .columns
19969                .iter()
19970                .any(|candidate| candidate.id == *column)
19971            {
19972                Ok(())
19973            } else {
19974                Err(MongrelError::InvalidArgument(format!(
19975                    "security expression references unknown column id {column}"
19976                )))
19977            }
19978        }
19979        SecurityExpr::And { left, right } | SecurityExpr::Or { left, right } => {
19980            validate_security_expression(left, schema)?;
19981            validate_security_expression(right, schema)
19982        }
19983        SecurityExpr::Not { expression } => validate_security_expression(expression, schema),
19984    }
19985}
19986
19987/// Remove canonical numeric table directories that no catalog generation owns.
19988fn sweep_unreferenced_table_dirs(root: &Path, cat: &Catalog) -> Result<()> {
19989    let referenced = cat
19990        .tables
19991        .iter()
19992        .filter(|entry| matches!(entry.state, TableState::Live | TableState::Building { .. }))
19993        .map(|entry| entry.table_id)
19994        .collect::<HashSet<_>>();
19995    let tables_dir = root.join(TABLES_DIR);
19996    let entries = match std::fs::read_dir(&tables_dir) {
19997        Ok(entries) => entries,
19998        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
19999        Err(error) => return Err(error.into()),
20000    };
20001    for entry in entries {
20002        let entry = entry?;
20003        if !entry.file_type()?.is_dir() {
20004            continue;
20005        }
20006        let file_name = entry.file_name();
20007        let Some(name) = file_name.to_str() else {
20008            continue;
20009        };
20010        let Ok(table_id) = name.parse::<u64>() else {
20011            continue;
20012        };
20013        if name != table_id.to_string() {
20014            continue;
20015        }
20016        if !referenced.contains(&table_id) {
20017            crate::durable_file::remove_directory_all(&entry.path())?;
20018        }
20019    }
20020    Ok(())
20021}
20022
20023/// Sweep stale `_txn/<txn_id>/` dirs from every table (spec §8.5, review fix
20024/// #14). These dirs hold pending uniform-epoch runs from large transactions
20025/// that were aborted or crashed before commit. On open, all such dirs are safe
20026/// to remove because committed txns moved their runs to `_runs/` at publish.
20027fn sweep_pending_txn_dirs(root: &Path, cat: &Catalog) {
20028    for entry in &cat.tables {
20029        let txn_dir = root
20030            .join(TABLES_DIR)
20031            .join(entry.table_id.to_string())
20032            .join("_txn");
20033        if txn_dir.exists() {
20034            let _ = std::fs::remove_dir_all(&txn_dir);
20035        }
20036    }
20037}
20038
20039#[cfg(test)]
20040mod write_permission_tests {
20041    use super::*;
20042    use crate::txn::Staged;
20043
20044    struct NoopExternalBridge;
20045
20046    impl ExternalTriggerBridge for NoopExternalBridge {
20047        fn apply_trigger_external_write(
20048            &self,
20049            _entry: &ExternalTableEntry,
20050            base_state: Vec<u8>,
20051            _op: ExternalTriggerWrite,
20052        ) -> Result<ExternalTriggerWriteResult> {
20053            Ok(ExternalTriggerWriteResult::new(base_state))
20054        }
20055    }
20056
20057    fn assert_txn_namespace_full<T>(result: Result<T>) {
20058        assert!(matches!(result, Err(MongrelError::Full(_))));
20059    }
20060
20061    #[test]
20062    fn every_begin_api_preserves_transaction_id_exhaustion_without_wal_mutation() {
20063        let directory = tempfile::tempdir().unwrap();
20064        let database = Database::create(directory.path()).unwrap();
20065        let generation = (*database.next_txn_id.lock() >> 32).saturating_add(1);
20066        *database.next_txn_id.lock() = generation << 32;
20067        let before = crate::wal::SharedWal::replay(directory.path())
20068            .unwrap()
20069            .len();
20070        let bridge = NoopExternalBridge;
20071
20072        assert_txn_namespace_full(database.begin().commit());
20073        assert_txn_namespace_full(database.begin_as(None).commit_with_row_ids());
20074        assert_txn_namespace_full(
20075            database
20076                .begin_with_isolation(crate::txn::IsolationLevel::Serializable)
20077                .commit(),
20078        );
20079        assert_txn_namespace_full(
20080            database
20081                .begin_with_external_trigger_bridge(&bridge)
20082                .commit(),
20083        );
20084        assert_txn_namespace_full(
20085            database
20086                .begin_with_external_trigger_bridge_as(&bridge, None)
20087                .commit_controlled(&crate::ExecutionControl::new(None), || Ok(())),
20088        );
20089
20090        assert_eq!(
20091            crate::wal::SharedWal::replay(directory.path())
20092                .unwrap()
20093                .len(),
20094            before
20095        );
20096        drop(database);
20097        Database::open(directory.path()).unwrap();
20098    }
20099
20100    #[test]
20101    fn recovered_storage_identity_mismatch_does_not_mutate_directory() {
20102        let directory = tempfile::tempdir().unwrap();
20103        let table_dir = directory.path().join("7");
20104        crate::durable_file::create_directory_all(&table_dir).unwrap();
20105        let original_schema = test_schema();
20106        crate::engine::write_schema(&table_dir, &original_schema).unwrap();
20107        let mut manifest = crate::manifest::Manifest::new(8, original_schema.schema_id);
20108        crate::manifest::write_atomic(&table_dir, &mut manifest, None).unwrap();
20109        let schema_path = table_dir.join(crate::engine::SCHEMA_FILENAME);
20110        let original_bytes = std::fs::read(&schema_path).unwrap();
20111
20112        let mut replacement_schema = original_schema;
20113        replacement_schema.schema_id += 1;
20114        assert!(matches!(
20115            ensure_recovered_table_storage(None, None, &table_dir, 7, &replacement_schema, None,),
20116            Err(MongrelError::Conflict(_))
20117        ));
20118
20119        assert_eq!(std::fs::read(schema_path).unwrap(), original_bytes);
20120        assert!(!table_dir.join(crate::engine::WAL_DIR).exists());
20121        assert!(!table_dir.join(crate::engine::RUNS_DIR).exists());
20122        assert_eq!(crate::manifest::read(&table_dir, None).unwrap().table_id, 8);
20123    }
20124
20125    #[test]
20126    fn catalog_table_missing_storage_fails_without_recreating_it() {
20127        let directory = tempfile::tempdir().unwrap();
20128        let table_dir = {
20129            let database = Database::create(directory.path()).unwrap();
20130            database.create_table("docs", test_schema()).unwrap();
20131            directory
20132                .path()
20133                .join(TABLES_DIR)
20134                .join(database.table_id("docs").unwrap().to_string())
20135        };
20136        std::fs::remove_dir_all(&table_dir).unwrap();
20137
20138        assert!(matches!(
20139            Database::open(directory.path()),
20140            Err(MongrelError::NotFound(_))
20141        ));
20142        assert!(!table_dir.exists());
20143    }
20144
20145    #[test]
20146    fn authentication_and_principal_resolution_share_one_catalog_snapshot() {
20147        let directory = tempfile::tempdir().unwrap();
20148        let database = std::sync::Arc::new(
20149            Database::create_with_credentials(directory.path(), "admin", "admin-password").unwrap(),
20150        );
20151        database.create_user("alice", "old-password").unwrap();
20152        let old_identity = database.user_identity("alice").unwrap();
20153        let (verified_tx, verified_rx) = std::sync::mpsc::channel();
20154        let (resume_tx, resume_rx) = std::sync::mpsc::channel();
20155        let (mutation_started_tx, mutation_started_rx) = std::sync::mpsc::channel();
20156        let (mutation_done_tx, mutation_done_rx) = std::sync::mpsc::channel();
20157
20158        std::thread::scope(|scope| {
20159            let authenticate = {
20160                let database = std::sync::Arc::clone(&database);
20161                scope.spawn(move || {
20162                    database.authenticate_principal_inner("alice", "old-password", || {
20163                        verified_tx.send(()).unwrap();
20164                        resume_rx.recv().unwrap();
20165                    })
20166                })
20167            };
20168            verified_rx.recv().unwrap();
20169            let mutate = {
20170                let database = std::sync::Arc::clone(&database);
20171                scope.spawn(move || {
20172                    mutation_started_tx.send(()).unwrap();
20173                    database.drop_user("alice").unwrap();
20174                    database.create_user("alice", "new-password").unwrap();
20175                    mutation_done_tx.send(()).unwrap();
20176                })
20177            };
20178            mutation_started_rx.recv().unwrap();
20179            assert!(mutation_done_rx
20180                .recv_timeout(std::time::Duration::from_millis(50))
20181                .is_err());
20182            resume_tx.send(()).unwrap();
20183            let principal = authenticate.join().unwrap().unwrap().unwrap();
20184            assert_eq!((principal.user_id, principal.created_epoch), old_identity);
20185            mutate.join().unwrap();
20186        });
20187
20188        assert_ne!(database.user_identity("alice").unwrap(), old_identity);
20189        assert!(database
20190            .authenticate_principal("alice", "old-password")
20191            .unwrap()
20192            .is_none());
20193        assert!(database
20194            .authenticate_principal("alice", "new-password")
20195            .unwrap()
20196            .is_some());
20197    }
20198
20199    #[test]
20200    fn homogeneous_batch_summarizes_to_one_permission_decision() {
20201        let staging = (0..10_050)
20202            .map(|_| {
20203                (
20204                    7,
20205                    Staged::Put(vec![(2, Value::Int64(2)), (1, Value::Int64(1))]),
20206                )
20207            })
20208            .collect::<Vec<_>>();
20209
20210        let needs = summarize_write_permissions(&staging);
20211        let table = needs.get(&7).unwrap();
20212        assert_eq!(needs.len(), 1);
20213        assert!(table.insert);
20214        assert_eq!(table.insert_columns, [1, 2]);
20215        assert!(!table.update);
20216        assert!(!table.delete);
20217        assert!(!table.truncate);
20218    }
20219
20220    #[test]
20221    fn mixed_writes_union_columns_and_preserve_empty_operations() {
20222        let staging = vec![
20223            (7, Staged::Put(vec![(2, Value::Int64(2))])),
20224            (7, Staged::Put(vec![(1, Value::Int64(1))])),
20225            (
20226                7,
20227                Staged::Update {
20228                    row_id: RowId(1),
20229                    new_row: vec![(1, Value::Int64(1)), (2, Value::Int64(2))],
20230                    changed_columns: vec![2],
20231                },
20232            ),
20233            (7, Staged::Delete(RowId(2))),
20234            (8, Staged::Truncate),
20235        ];
20236
20237        let needs = summarize_write_permissions(&staging);
20238        let table = needs.get(&7).unwrap();
20239        assert_eq!(table.insert_columns, [1, 2]);
20240        assert!(table.update);
20241        assert_eq!(table.update_columns, [2]);
20242        assert!(table.delete);
20243        assert!(needs.get(&8).unwrap().truncate);
20244    }
20245
20246    #[test]
20247    fn final_permission_decisions_do_not_scale_with_rows() {
20248        let credentialless_dir = tempfile::tempdir().unwrap();
20249        let credentialless = Database::create(credentialless_dir.path()).unwrap();
20250        credentialless.create_table("docs", test_schema()).unwrap();
20251        WRITE_PERMISSION_DECISIONS.with(|decisions| decisions.set(0));
20252        credentialless
20253            .validate_write_permissions(&puts(credentialless.table_id("docs").unwrap()), None, None)
20254            .unwrap();
20255        WRITE_PERMISSION_DECISIONS.with(|decisions| assert_eq!(decisions.get(), 0));
20256
20257        let authenticated_dir = tempfile::tempdir().unwrap();
20258        let authenticated =
20259            Database::create_with_credentials(authenticated_dir.path(), "admin", "admin-password")
20260                .unwrap();
20261        authenticated.create_table("docs", test_schema()).unwrap();
20262        let admin = authenticated.resolve_principal("admin").unwrap();
20263        WRITE_PERMISSION_DECISIONS.with(|decisions| decisions.set(0));
20264        authenticated
20265            .validate_write_permissions(
20266                &puts(authenticated.table_id("docs").unwrap()),
20267                Some(&admin),
20268                None,
20269            )
20270            .unwrap();
20271        WRITE_PERMISSION_DECISIONS.with(|decisions| assert_eq!(decisions.get(), 1));
20272    }
20273
20274    #[test]
20275    fn delete_batch_checks_permission_once_when_staged_and_once_when_committed() {
20276        let dir = tempfile::tempdir().unwrap();
20277        let db = Database::create_with_credentials(dir.path(), "admin", "admin-password").unwrap();
20278        db.create_table("docs", test_schema()).unwrap();
20279        let admin = db.resolve_principal("admin").unwrap();
20280        TABLE_PERMISSION_DECISIONS.with(|decisions| decisions.set(0));
20281
20282        let mut transaction = db.begin_as(Some(admin));
20283        transaction
20284            .delete_batch("docs", (0..100).map(RowId).collect())
20285            .unwrap();
20286        transaction.commit().unwrap();
20287
20288        TABLE_PERMISSION_DECISIONS.with(|decisions| assert_eq!(decisions.get(), 2));
20289    }
20290
20291    #[test]
20292    fn truncate_validation_checks_admin_once_for_all_tables() {
20293        let dir = tempfile::tempdir().unwrap();
20294        let db = Database::create_with_credentials(dir.path(), "admin", "admin-password").unwrap();
20295        db.create_table("first", test_schema()).unwrap();
20296        db.create_table("second", test_schema()).unwrap();
20297        let admin = db.resolve_principal("admin").unwrap();
20298        let staging = vec![
20299            (db.table_id("first").unwrap(), Staged::Truncate),
20300            (db.table_id("second").unwrap(), Staged::Truncate),
20301        ];
20302
20303        TABLE_PERMISSION_DECISIONS.with(|decisions| decisions.set(0));
20304        db.validate_write_permissions(&staging, Some(&admin), None)
20305            .unwrap();
20306        TABLE_PERMISSION_DECISIONS.with(|decisions| assert_eq!(decisions.get(), 1));
20307    }
20308
20309    #[test]
20310    fn one_table_commit_batches_structural_work() {
20311        let dir = tempfile::tempdir().unwrap();
20312        let db = Database::create(dir.path()).unwrap();
20313        db.create_table("docs", test_schema()).unwrap();
20314        let table_id = db.table_id("docs").unwrap();
20315
20316        AUTO_INCREMENT_TABLE_LOCKS.with(|count| count.set(0));
20317        PREBUILD_TABLE_LOCKS.with(|count| count.set(0));
20318        PUBLISH_TABLE_LOCKS.with(|count| count.set(0));
20319        COMMIT_MANIFEST_WRITES.with(|count| count.set(0));
20320        db.transaction(|transaction| {
20321            for id in 0..100 {
20322                transaction.put("docs", vec![(1, Value::Int64(id))])?;
20323            }
20324            Ok(())
20325        })
20326        .unwrap();
20327
20328        AUTO_INCREMENT_TABLE_LOCKS.with(|count| assert_eq!(count.get(), 2));
20329        PREBUILD_TABLE_LOCKS.with(|count| assert_eq!(count.get(), 1));
20330        PUBLISH_TABLE_LOCKS.with(|count| assert_eq!(count.get(), 1));
20331        COMMIT_MANIFEST_WRITES.with(|count| assert_eq!(count.get(), 1));
20332
20333        let puts = crate::wal::SharedWal::replay(dir.path())
20334            .unwrap()
20335            .into_iter()
20336            .filter_map(|record| match record.op {
20337                crate::wal::Op::Put { table_id: id, rows } if id == table_id => Some(
20338                    bincode::deserialize::<Vec<crate::memtable::Row>>(&rows)
20339                        .unwrap()
20340                        .len(),
20341                ),
20342                _ => None,
20343            })
20344            .collect::<Vec<_>>();
20345        assert_eq!(puts, [100]);
20346
20347        let row_ids = db
20348            .table("docs")
20349            .unwrap()
20350            .lock()
20351            .visible_rows(db.snapshot().0)
20352            .unwrap()
20353            .into_iter()
20354            .take(2)
20355            .map(|row| row.row_id)
20356            .collect::<Vec<_>>();
20357        PREBUILD_TABLE_LOCKS.with(|count| count.set(0));
20358        PUBLISH_TABLE_LOCKS.with(|count| count.set(0));
20359        COMMIT_MANIFEST_WRITES.with(|count| count.set(0));
20360        db.transaction(|transaction| {
20361            for row_id in row_ids {
20362                transaction.delete("docs", row_id)?;
20363            }
20364            Ok(())
20365        })
20366        .unwrap();
20367        PREBUILD_TABLE_LOCKS.with(|count| assert_eq!(count.get(), 1));
20368        PUBLISH_TABLE_LOCKS.with(|count| assert_eq!(count.get(), 1));
20369        COMMIT_MANIFEST_WRITES.with(|count| assert_eq!(count.get(), 1));
20370
20371        let deletes = crate::wal::SharedWal::replay(dir.path())
20372            .unwrap()
20373            .into_iter()
20374            .filter_map(|record| match record.op {
20375                crate::wal::Op::Delete {
20376                    table_id: id,
20377                    row_ids,
20378                } if id == table_id => Some(row_ids.len()),
20379                _ => None,
20380            })
20381            .collect::<Vec<_>>();
20382        assert_eq!(deletes, [2]);
20383    }
20384
20385    fn puts(table_id: u64) -> Vec<(u64, Staged)> {
20386        (0..10_050)
20387            .map(|id| (table_id, Staged::Put(vec![(1, Value::Int64(id))])))
20388            .collect()
20389    }
20390
20391    fn test_schema() -> Schema {
20392        Schema {
20393            columns: vec![ColumnDef {
20394                id: 1,
20395                name: "id".into(),
20396                ty: TypeId::Int64,
20397                flags: crate::schema::ColumnFlags::empty()
20398                    .with(crate::schema::ColumnFlags::PRIMARY_KEY),
20399                default_value: None,
20400                embedding_source: None,
20401            }],
20402            ..Schema::default()
20403        }
20404    }
20405}
20406
20407#[cfg(test)]
20408mod cdc_bounds_tests {
20409    use super::*;
20410
20411    #[test]
20412    fn retained_byte_limit_rejects_without_allocating_payload() {
20413        let mut retained = 0;
20414        let error = charge_cdc_bytes(
20415            &mut retained,
20416            CDC_MAX_RETAINED_BYTES.saturating_add(1),
20417            "CDC retained bytes",
20418        )
20419        .unwrap_err();
20420        assert!(matches!(
20421            error,
20422            MongrelError::ResourceLimitExceeded {
20423                resource: "CDC retained bytes",
20424                ..
20425            }
20426        ));
20427    }
20428
20429    #[test]
20430    fn row_json_estimate_accounts_for_byte_array_expansion() {
20431        let row = crate::memtable::Row::new(RowId(1), Epoch(1))
20432            .with_column(1, Value::Bytes(vec![0; 1024]));
20433        assert!(cdc_row_json_bytes(&row) >= 1024 * std::mem::size_of::<serde_json::Value>());
20434    }
20435}
20436
20437#[cfg(test)]
20438mod generation_metrics_tests {
20439    use super::*;
20440    use crate::schema::{ColumnDef, ColumnFlags, Schema, TypeId};
20441
20442    #[test]
20443    fn legacy_cow_fallback_is_measured() {
20444        let dir = tempfile::tempdir().unwrap();
20445        let table = Table::create(
20446            dir.path(),
20447            Schema {
20448                columns: vec![ColumnDef {
20449                    id: 1,
20450                    name: "id".into(),
20451                    ty: TypeId::Int64,
20452                    flags: ColumnFlags::empty().with(ColumnFlags::PRIMARY_KEY),
20453                    default_value: None,
20454                    embedding_source: None,
20455                }],
20456                ..Schema::default()
20457            },
20458            1,
20459        )
20460        .unwrap();
20461        let handle = TableHandle::from_table(table);
20462        let held = match &handle.inner {
20463            TableHandleInner::CopyOnWrite(slot) => Arc::clone(&slot.read()),
20464            TableHandleInner::Direct(_) => unreachable!(),
20465        };
20466
20467        handle.lock().set_sync_byte_threshold(1);
20468
20469        let stats = handle.generation_stats();
20470        assert_eq!(stats.cow_clone_count, 1);
20471        assert!(stats.estimated_cow_clone_bytes > 0);
20472        drop(held);
20473    }
20474}
20475
20476#[cfg(test)]
20477mod trigger_engine_tests {
20478    use super::*;
20479
20480    fn event_with(new_cells: &[(u16, Value)], old_cells: &[(u16, Value)]) -> WriteEvent {
20481        WriteEvent {
20482            table: "test".into(),
20483            kind: TriggerEvent::Insert,
20484            new: Some(TriggerRowImage {
20485                columns: new_cells.iter().cloned().collect(),
20486            }),
20487            old: Some(TriggerRowImage {
20488                columns: old_cells.iter().cloned().collect(),
20489            }),
20490            changed_columns: Vec::new(),
20491            op_indices: Vec::new(),
20492            put_idx: None,
20493            trigger_stack: Vec::new(),
20494        }
20495    }
20496
20497    fn event_insert(new_cells: &[(u16, Value)]) -> WriteEvent {
20498        WriteEvent {
20499            table: "test".into(),
20500            kind: TriggerEvent::Insert,
20501            new: Some(TriggerRowImage {
20502                columns: new_cells.iter().cloned().collect(),
20503            }),
20504            old: None,
20505            changed_columns: Vec::new(),
20506            op_indices: Vec::new(),
20507            put_idx: None,
20508            trigger_stack: Vec::new(),
20509        }
20510    }
20511
20512    #[test]
20513    fn value_order_int64_vs_float64() {
20514        assert_eq!(
20515            value_order(&Value::Int64(5), &Value::Float64(5.0)),
20516            Some(std::cmp::Ordering::Equal)
20517        );
20518        assert_eq!(
20519            value_order(&Value::Int64(5), &Value::Float64(3.0)),
20520            Some(std::cmp::Ordering::Greater)
20521        );
20522        assert_eq!(
20523            value_order(&Value::Int64(2), &Value::Float64(3.0)),
20524            Some(std::cmp::Ordering::Less)
20525        );
20526    }
20527
20528    #[test]
20529    fn value_order_null_returns_none() {
20530        assert_eq!(value_order(&Value::Int64(5), &Value::Null), None);
20531        assert_eq!(value_order(&Value::Null, &Value::Int64(5)), None);
20532        assert_eq!(value_order(&Value::Null, &Value::Null), None);
20533    }
20534
20535    #[test]
20536    fn value_order_cross_group_returns_none() {
20537        assert_eq!(
20538            value_order(&Value::Int64(5), &Value::Bytes(b"x".to_vec())),
20539            None
20540        );
20541        assert_eq!(value_order(&Value::Bool(true), &Value::Int64(1)), None);
20542        assert_eq!(
20543            value_order(
20544                &Value::Embedding(vec![1.0, 2.0]),
20545                &Value::Embedding(vec![1.0, 2.0])
20546            ),
20547            None
20548        );
20549    }
20550
20551    #[test]
20552    fn eval_trigger_expr_ranges_and_booleans() {
20553        let expr = TriggerExpr::And {
20554            left: Box::new(TriggerExpr::Gt {
20555                left: TriggerValue::NewColumn(1),
20556                right: TriggerValue::Literal(Value::Int64(0)),
20557            }),
20558            right: Box::new(TriggerExpr::Lte {
20559                left: TriggerValue::NewColumn(1),
20560                right: TriggerValue::Literal(Value::Int64(100)),
20561            }),
20562        };
20563        assert!(eval_trigger_expr(&expr, &event_insert(&[(1, Value::Int64(50))])).unwrap());
20564        assert!(!eval_trigger_expr(&expr, &event_insert(&[(1, Value::Int64(200))])).unwrap());
20565        assert!(!eval_trigger_expr(&expr, &event_insert(&[(1, Value::Null)])).unwrap());
20566
20567        let or_expr = TriggerExpr::Or {
20568            left: Box::new(TriggerExpr::Lt {
20569                left: TriggerValue::NewColumn(1),
20570                right: TriggerValue::Literal(Value::Int64(0)),
20571            }),
20572            right: Box::new(TriggerExpr::Not(Box::new(TriggerExpr::IsNull(
20573                TriggerValue::OldColumn(2),
20574            )))),
20575        };
20576        assert!(eval_trigger_expr(
20577            &or_expr,
20578            &event_with(&[(1, Value::Int64(5))], &[(2, Value::Int64(99))])
20579        )
20580        .unwrap());
20581        assert!(!eval_trigger_expr(
20582            &or_expr,
20583            &event_with(&[(1, Value::Int64(5))], &[(2, Value::Null)])
20584        )
20585        .unwrap());
20586
20587        assert!(eval_trigger_expr(
20588            &TriggerExpr::Value(TriggerValue::Literal(Value::Bool(true))),
20589            &event_insert(&[])
20590        )
20591        .unwrap());
20592        assert!(!eval_trigger_expr(
20593            &TriggerExpr::Value(TriggerValue::Literal(Value::Bool(false))),
20594            &event_insert(&[])
20595        )
20596        .unwrap());
20597        assert!(!eval_trigger_expr(
20598            &TriggerExpr::Value(TriggerValue::Literal(Value::Null)),
20599            &event_insert(&[])
20600        )
20601        .unwrap());
20602    }
20603}
20604
20605#[cfg(test)]
20606mod core_resource_tests {
20607    use super::*;
20608
20609    fn int_pk_schema() -> Schema {
20610        Schema {
20611            columns: vec![ColumnDef {
20612                id: 1,
20613                name: "id".into(),
20614                ty: TypeId::Int64,
20615                flags: crate::schema::ColumnFlags::empty()
20616                    .with(crate::schema::ColumnFlags::PRIMARY_KEY),
20617                default_value: None,
20618                embedding_source: None,
20619            }],
20620            ..Schema::default()
20621        }
20622    }
20623
20624    #[test]
20625    fn open_constructs_governor_spill_and_jobs_with_documented_defaults() {
20626        let dir = tempfile::tempdir().unwrap();
20627        let db = Database::create(dir.path()).unwrap();
20628        assert_eq!(
20629            db.memory_governor().max_bytes(),
20630            DEFAULT_MEMORY_BUDGET_BYTES
20631        );
20632        assert_eq!(
20633            db.spill_manager().config().global_bytes,
20634            DEFAULT_TEMP_DISK_BUDGET_BYTES
20635        );
20636        assert!(db.job_registry().list().is_empty());
20637        // S1E-002: class defaults seeded at open; no external embedding vendor.
20638        assert_eq!(
20639            db.resource_groups().len(),
20640            crate::resource::WorkloadClass::ALL.len()
20641        );
20642        assert!(db.resource_groups().get("control").is_some());
20643        assert!(db.embedding_providers().list_ids().is_empty());
20644        // Application-supplied path refuses generation (no silent hashed vectors).
20645        let err = db
20646            .embedding_providers()
20647            .embed(
20648                &crate::embedding::EmbeddingSource::SuppliedByApplication,
20649                &["text"],
20650                4,
20651            )
20652            .unwrap_err();
20653        assert!(matches!(
20654            err,
20655            crate::embedding::EmbeddingError::SuppliedByApplication
20656        ));
20657    }
20658
20659    #[test]
20660    fn lock_rows_for_update_acquires_exclusive_row_locks() {
20661        use crate::locks::LockKey;
20662        use crate::rowid::RowId;
20663
20664        let dir = tempfile::tempdir().unwrap();
20665        let db = Database::create(dir.path()).unwrap();
20666        let txn_id = db.allocate_lock_txn_id().unwrap();
20667        let rid = RowId(42);
20668        db.lock_rows_for_update(txn_id, 7, &[rid], None).unwrap();
20669        assert!(db.lock_manager().holds(txn_id, &LockKey::row(7, rid)));
20670        db.release_txn_locks(txn_id);
20671        assert!(!db.lock_manager().holds(txn_id, &LockKey::row(7, rid)));
20672    }
20673
20674    #[test]
20675    fn open_with_options_sizes_the_core_budgets() {
20676        let dir = tempfile::tempdir().unwrap();
20677        let db = Database::create(dir.path()).unwrap();
20678        drop(db);
20679        let db = Database::open_with_options(
20680            dir.path(),
20681            OpenOptions::default()
20682                .with_memory_budget_bytes(256 * 1024 * 1024)
20683                .with_temp_disk_budget_bytes(16 * 1024 * 1024),
20684        )
20685        .unwrap();
20686        assert_eq!(db.memory_governor().max_bytes(), 256 * 1024 * 1024);
20687        assert_eq!(db.spill_manager().config().global_bytes, 16 * 1024 * 1024);
20688    }
20689
20690    #[test]
20691    fn zero_budgets_are_rejected() {
20692        let dir = tempfile::tempdir().unwrap();
20693        let db = Database::create(dir.path()).unwrap();
20694        drop(db);
20695        let result = Database::open_with_options(
20696            dir.path(),
20697            OpenOptions::default().with_memory_budget_bytes(0),
20698        );
20699        assert!(matches!(result, Err(MongrelError::InvalidArgument(_))));
20700        let result = Database::open_with_options(
20701            dir.path(),
20702            OpenOptions::default().with_temp_disk_budget_bytes(0),
20703        );
20704        assert!(matches!(result, Err(MongrelError::InvalidArgument(_))));
20705    }
20706
20707    #[test]
20708    fn page_caches_reserve_under_the_governor() {
20709        let dir = tempfile::tempdir().unwrap();
20710        let db = Database::create(dir.path()).unwrap();
20711        db.create_table("t", int_pk_schema()).unwrap();
20712        let mut txn = db.begin();
20713        txn.put("t", vec![(1, Value::Int64(7))]).unwrap();
20714        txn.commit().unwrap();
20715        // S1E-003: both caches hold reservations under the core's governor, so
20716        // the governor's per-class accounting mirrors their live bytes, and
20717        // both are registered as reclaimable subsystems it can evict.
20718        let stats = db.memory_governor().stats();
20719        assert_eq!(
20720            stats.usage_for(crate::memory::MemoryClass::PageCache),
20721            db.page_cache.used_bytes()
20722        );
20723        assert_eq!(
20724            stats.usage_for(crate::memory::MemoryClass::DecodedCache),
20725            db.decoded_cache.used_bytes()
20726        );
20727        assert_eq!(
20728            db.memory_governor().reclaimable_bytes(),
20729            db.page_cache.used_bytes() + db.decoded_cache.used_bytes()
20730        );
20731        // Driving an eviction through the governor is safe at any level.
20732        let _ = db.memory_governor().evict_reclaimable(1024 * 1024);
20733    }
20734
20735    #[test]
20736    fn job_registry_persists_across_reopen() {
20737        let dir = tempfile::tempdir().unwrap();
20738        let db = Database::create(dir.path()).unwrap();
20739        db.create_table("t", int_pk_schema()).unwrap();
20740        let job_id = db
20741            .job_registry()
20742            .submit(
20743                crate::jobs::JobKind::IndexBuild,
20744                crate::jobs::JobTarget {
20745                    table: "t".to_string(),
20746                    index: Some("idx".to_string()),
20747                },
20748            )
20749            .unwrap();
20750        drop(db);
20751        let db = Database::open(dir.path()).unwrap();
20752        let record = db.job_registry().get(job_id).expect("job survives reopen");
20753        assert_eq!(record.state, crate::jobs::JobState::Pending);
20754    }
20755
20756    #[test]
20757    fn spill_manager_open_sweeps_stale_temp_tree() {
20758        let dir = tempfile::tempdir().unwrap();
20759        let db = Database::create(dir.path()).unwrap();
20760        let stale = dir.path().join("temp").join("spill").join("q-deadbeef");
20761        std::fs::create_dir_all(&stale).unwrap();
20762        std::fs::write(stale.join("chunk-0"), b"stale").unwrap();
20763        drop(db);
20764        let db = Database::open(dir.path()).unwrap();
20765        assert!(
20766            !stale.exists(),
20767            "the startup sweep removes stale spill files (S1E-004)"
20768        );
20769        // A fresh session can start against the swept manager.
20770        let session = db
20771            .spill_manager()
20772            .begin_query(
20773                mongreldb_types::ids::QueryId::from_bytes([7u8; 16]),
20774                1024 * 1024,
20775            )
20776            .unwrap();
20777        assert_eq!(session.used(), 0);
20778    }
20779}
20780
20781#[cfg(test)]
20782mod version_pin_tests {
20783    use super::*;
20784
20785    fn int_pk_schema() -> Schema {
20786        Schema {
20787            columns: vec![ColumnDef {
20788                id: 1,
20789                name: "id".into(),
20790                ty: TypeId::Int64,
20791                flags: crate::schema::ColumnFlags::empty()
20792                    .with(crate::schema::ColumnFlags::PRIMARY_KEY),
20793                default_value: None,
20794                embedding_source: None,
20795            }],
20796            ..Schema::default()
20797        }
20798    }
20799
20800    fn pins_for(
20801        report: &[TablePinsReport],
20802        table: &str,
20803        source: crate::retention::PinSource,
20804    ) -> Option<crate::retention::PinInfo> {
20805        report
20806            .iter()
20807            .find(|entry| entry.table == table)
20808            .and_then(|entry| entry.pins.get(source).cloned())
20809    }
20810
20811    #[test]
20812    fn backup_boundary_registers_backup_pitr_pin() {
20813        let source = tempfile::tempdir().unwrap();
20814        let destination_parent = tempfile::tempdir().unwrap();
20815        let destination = destination_parent.path().join("backup");
20816        let db = Arc::new(Database::create(source.path()).unwrap());
20817        db.create_table("t", int_pk_schema()).unwrap();
20818        let mut txn = db.begin();
20819        txn.put("t", vec![(1, Value::Int64(1))]).unwrap();
20820        let boundary_epoch = txn.commit().unwrap();
20821
20822        let hold = Arc::new(std::sync::Barrier::new(2));
20823        let resume = Arc::new(std::sync::Barrier::new(2));
20824        db.__set_backup_hook({
20825            let hold = Arc::clone(&hold);
20826            let resume = Arc::clone(&resume);
20827            move || {
20828                hold.wait();
20829                resume.wait();
20830            }
20831        });
20832
20833        let backup = {
20834            let db = Arc::clone(&db);
20835            let destination = destination.clone();
20836            std::thread::spawn(move || db.hot_backup(destination))
20837        };
20838        hold.wait();
20839        // The hook fires while the backup's pins are held: the boundary must
20840        // show up as a BackupPitr pin on the table's unified registry.
20841        let report = db.version_pins_report();
20842        let pin = pins_for(&report, "t", crate::retention::PinSource::BackupPitr)
20843            .expect("backup boundary must register a BackupPitr pin");
20844        assert_eq!(pin.oldest_epoch, boundary_epoch);
20845        assert!(pin.pin_count >= 1);
20846        resume.wait();
20847        backup.join().unwrap().unwrap();
20848
20849        let report = db.version_pins_report();
20850        assert!(
20851            pins_for(&report, "t", crate::retention::PinSource::BackupPitr).is_none(),
20852            "the BackupPitr pin releases when the backup finishes"
20853        );
20854    }
20855
20856    #[test]
20857    fn snapshot_and_read_generation_pins_surface_in_report() {
20858        let dir = tempfile::tempdir().unwrap();
20859        let db = Database::create(dir.path()).unwrap();
20860        db.create_table("t", int_pk_schema()).unwrap();
20861        let mut txn = db.begin();
20862        txn.put("t", vec![(1, Value::Int64(7))]).unwrap();
20863        txn.commit().unwrap();
20864
20865        let (_snapshot, guard) = db.snapshot();
20866        let report = db.version_pins_report();
20867        assert!(
20868            pins_for(
20869                &report,
20870                "t",
20871                crate::retention::PinSource::TransactionSnapshot
20872            )
20873            .is_some(),
20874            "a database snapshot projects the TransactionSnapshot source"
20875        );
20876        drop(guard);
20877
20878        let handle = db.table("t").unwrap();
20879        let (generation, _snapshot) = handle.read_generation_with_context(None).unwrap();
20880        let report = db.version_pins_report();
20881        assert!(
20882            pins_for(&report, "t", crate::retention::PinSource::ReadGeneration).is_some(),
20883            "a cloned read generation registers a ReadGeneration pin"
20884        );
20885        drop(generation);
20886
20887        let report = db.version_pins_report();
20888        let entry = report.iter().find(|entry| entry.table == "t").unwrap();
20889        assert!(
20890            entry
20891                .pins
20892                .get(crate::retention::PinSource::BackupPitr)
20893                .is_none()
20894                && entry
20895                    .pins
20896                    .get(crate::retention::PinSource::Replication)
20897                    .is_none()
20898                && entry
20899                    .pins
20900                    .get(crate::retention::PinSource::OnlineIndexBuild)
20901                    .is_none(),
20902            "untaken sources stay absent from the report"
20903        );
20904    }
20905}
20906
20907#[cfg(test)]
20908mod lock_manager_tests {
20909    use super::*;
20910    use crate::locks::LockKey;
20911
20912    fn col(id: u16, name: &str, ty: TypeId, flags: crate::schema::ColumnFlags) -> ColumnDef {
20913        ColumnDef {
20914            id,
20915            name: name.into(),
20916            ty,
20917            flags,
20918            default_value: None,
20919            embedding_source: None,
20920        }
20921    }
20922
20923    fn unique_schema() -> Schema {
20924        let mut constraints = crate::constraint::TableConstraints::default();
20925        constraints
20926            .uniques
20927            .push(crate::constraint::UniqueConstraint {
20928                id: 1,
20929                name: "users_email_unique".into(),
20930                columns: vec![1],
20931            });
20932        Schema {
20933            columns: vec![
20934                col(
20935                    0,
20936                    "id",
20937                    TypeId::Int64,
20938                    crate::schema::ColumnFlags::empty()
20939                        .with(crate::schema::ColumnFlags::PRIMARY_KEY),
20940                ),
20941                col(
20942                    1,
20943                    "email",
20944                    TypeId::Bytes,
20945                    crate::schema::ColumnFlags::empty().with(crate::schema::ColumnFlags::NULLABLE),
20946                ),
20947            ],
20948            constraints,
20949            ..Schema::default()
20950        }
20951    }
20952
20953    fn parent_schema() -> Schema {
20954        Schema {
20955            columns: vec![col(
20956                0,
20957                "id",
20958                TypeId::Int64,
20959                crate::schema::ColumnFlags::empty().with(crate::schema::ColumnFlags::PRIMARY_KEY),
20960            )],
20961            ..Schema::default()
20962        }
20963    }
20964
20965    fn child_schema() -> Schema {
20966        let mut constraints = crate::constraint::TableConstraints::default();
20967        constraints
20968            .foreign_keys
20969            .push(crate::constraint::ForeignKey {
20970                id: 1,
20971                name: "child_parent_fk".into(),
20972                columns: vec![1],
20973                ref_table: "parent".into(),
20974                ref_columns: vec![0],
20975                on_delete: crate::constraint::FkAction::Restrict,
20976                on_update: crate::constraint::FkAction::Restrict,
20977            });
20978        Schema {
20979            columns: vec![
20980                col(
20981                    0,
20982                    "id",
20983                    TypeId::Int64,
20984                    crate::schema::ColumnFlags::empty()
20985                        .with(crate::schema::ColumnFlags::PRIMARY_KEY),
20986                ),
20987                col(
20988                    1,
20989                    "pid",
20990                    TypeId::Int64,
20991                    crate::schema::ColumnFlags::empty().with(crate::schema::ColumnFlags::NULLABLE),
20992                ),
20993            ],
20994            constraints,
20995            ..Schema::default()
20996        }
20997    }
20998
20999    fn auto_inc_schema() -> Schema {
21000        Schema {
21001            columns: vec![col(
21002                0,
21003                "id",
21004                TypeId::Int64,
21005                crate::schema::ColumnFlags::empty()
21006                    .with(crate::schema::ColumnFlags::PRIMARY_KEY)
21007                    .with(crate::schema::ColumnFlags::AUTO_INCREMENT),
21008            )],
21009            ..Schema::default()
21010        }
21011    }
21012
21013    fn pk_lock_key(table_id: u64, value: i64) -> LockKey {
21014        let mut key = b"pk:".to_vec();
21015        key.extend_from_slice(&Value::Int64(value).encode_key());
21016        LockKey::key(table_id, key)
21017    }
21018
21019    #[test]
21020    fn unique_claims_serialize_concurrent_commits() {
21021        let dir = tempfile::tempdir().unwrap();
21022        let db = Arc::new(Database::create(dir.path()).unwrap());
21023        let table_id = db.create_table("users", unique_schema()).unwrap();
21024        let pk_key = pk_lock_key(table_id, 1);
21025        let entered = Arc::new(std::sync::Barrier::new(2));
21026        let resume = Arc::new(std::sync::Barrier::new(2));
21027        let parked = Arc::new(AtomicBool::new(false));
21028        db.__set_catalog_commit_hook({
21029            let entered = Arc::clone(&entered);
21030            let resume = Arc::clone(&resume);
21031            let parked = Arc::clone(&parked);
21032            move || {
21033                // Park only the first commit to reach the sequencer; later
21034                // commits pass straight through.
21035                if !parked.swap(true, Ordering::SeqCst) {
21036                    entered.wait();
21037                    resume.wait();
21038                }
21039            }
21040        });
21041
21042        let mut txn_a = db.begin();
21043        txn_a
21044            .put(
21045                "users",
21046                vec![(0, Value::Int64(1)), (1, Value::Bytes(b"a@x".to_vec()))],
21047            )
21048            .unwrap();
21049        let a_id = txn_a.txn_id();
21050        let (a_tx, a_rx) = std::sync::mpsc::channel();
21051        let (b_tx, b_rx) = std::sync::mpsc::channel();
21052        std::thread::scope(|scope| {
21053            scope.spawn(|| {
21054                a_tx.send(txn_a.commit()).unwrap();
21055            });
21056            entered.wait();
21057            // A is parked in the sequencer holding its unique claims.
21058            assert!(
21059                db.lock_manager().holds(a_id, &pk_key),
21060                "primary-key claim must be held until the commit ends"
21061            );
21062            let mut uq_key = format!("uq{}:", 1).into_bytes();
21063            let cells_map: HashMap<u16, Value> = [(1u16, Value::Bytes(b"a@x".to_vec()))]
21064                .into_iter()
21065                .collect();
21066            uq_key.extend_from_slice(
21067                &crate::constraint::encode_composite_key(&[1], &cells_map).unwrap(),
21068            );
21069            assert!(
21070                db.lock_manager()
21071                    .holds(a_id, &LockKey::key(table_id, uq_key)),
21072                "declared-unique claim must be held until the commit ends"
21073            );
21074
21075            let mut txn_b = db.begin();
21076            txn_b
21077                .put(
21078                    "users",
21079                    vec![(0, Value::Int64(1)), (1, Value::Bytes(b"b@x".to_vec()))],
21080                )
21081                .unwrap();
21082            scope.spawn(|| {
21083                b_tx.send(txn_b.commit()).unwrap();
21084            });
21085            std::thread::sleep(std::time::Duration::from_millis(100));
21086            assert!(
21087                b_rx.try_recv().is_err(),
21088                "the concurrent claim must block until A ends its transaction"
21089            );
21090            resume.wait();
21091            assert!(a_rx.recv().unwrap().is_ok());
21092            let b_result = b_rx.recv().unwrap();
21093            assert!(
21094                matches!(b_result, Err(MongrelError::Conflict(_))),
21095                "the loser surfaces a conflict after serializing: {b_result:?}"
21096            );
21097        });
21098        assert!(
21099            !db.lock_manager().holds(a_id, &pk_key),
21100            "no phantom holds remain after the commit"
21101        );
21102    }
21103
21104    #[test]
21105    fn ddl_waits_for_inflight_dml_commit_on_schema_barrier() {
21106        let dir = tempfile::tempdir().unwrap();
21107        let db = Arc::new(Database::create(dir.path()).unwrap());
21108        db.create_table("parent", parent_schema()).unwrap();
21109        db.create_table("child", child_schema()).unwrap();
21110        let mut seed = db.begin();
21111        seed.put("parent", vec![(0, Value::Int64(1))]).unwrap();
21112        seed.commit().unwrap();
21113
21114        let entered = Arc::new(std::sync::Barrier::new(2));
21115        let resume = Arc::new(std::sync::Barrier::new(2));
21116        db.__set_fk_lock_hook({
21117            let entered = Arc::clone(&entered);
21118            let resume = Arc::clone(&resume);
21119            move || {
21120                entered.wait();
21121                resume.wait();
21122            }
21123        });
21124
21125        let mut txn_a = db.begin();
21126        txn_a
21127            .put("child", vec![(0, Value::Int64(100)), (1, Value::Int64(1))])
21128            .unwrap();
21129        let a_id = txn_a.txn_id();
21130        let (a_tx, a_rx) = std::sync::mpsc::channel();
21131        let (ddl_tx, ddl_rx) = std::sync::mpsc::channel();
21132        std::thread::scope(|scope| {
21133            scope.spawn(|| {
21134                a_tx.send(txn_a.commit()).unwrap();
21135            });
21136            entered.wait();
21137            // A is parked mid-validation: schema barrier held Shared.
21138            assert!(
21139                db.lock_manager().holds(a_id, &LockKey::schema_barrier()),
21140                "DML holds the schema barrier Shared for its commit"
21141            );
21142            let db = Arc::clone(&db);
21143            scope.spawn(move || {
21144                ddl_tx.send(db.drop_table("parent")).unwrap();
21145            });
21146            std::thread::sleep(std::time::Duration::from_millis(100));
21147            assert!(
21148                ddl_rx.try_recv().is_err(),
21149                "DDL must wait on the Exclusive schema barrier while DML is in flight"
21150            );
21151            resume.wait();
21152            // A now finishes and the waiting DDL proceeds. A's commit may
21153            // legitimately lose the publish race against the DDL's security
21154            // version advance — the designed "security policy changed during
21155            // write" outcome — or win it; the barrier guarantees only that the
21156            // DDL could not proceed before A released it.
21157            let a_result = a_rx.recv().unwrap();
21158            match &a_result {
21159                Ok(_) => {}
21160                Err(MongrelError::Conflict(message)) => {
21161                    assert!(
21162                        message.contains("security policy changed during write"),
21163                        "unexpected commit conflict: {message}"
21164                    );
21165                }
21166                other => panic!("unexpected commit outcome: {other:?}"),
21167            }
21168            assert!(ddl_rx.recv().unwrap().is_ok());
21169        });
21170        assert!(!db.lock_manager().holds(a_id, &LockKey::schema_barrier()));
21171    }
21172
21173    #[test]
21174    fn auto_increment_sequence_barrier_held_until_commit() {
21175        let dir = tempfile::tempdir().unwrap();
21176        let db = Database::create(dir.path()).unwrap();
21177        let table_id = db.create_table("seq_t", auto_inc_schema()).unwrap();
21178        let barrier_key = LockKey::sequence_barrier(format!("auto_inc:{table_id}").as_str());
21179        let entered = Arc::new(std::sync::Barrier::new(2));
21180        let resume = Arc::new(std::sync::Barrier::new(2));
21181        let parked = Arc::new(AtomicBool::new(false));
21182        db.__set_catalog_commit_hook({
21183            let entered = Arc::clone(&entered);
21184            let resume = Arc::clone(&resume);
21185            let parked = Arc::clone(&parked);
21186            move || {
21187                if !parked.swap(true, Ordering::SeqCst) {
21188                    entered.wait();
21189                    resume.wait();
21190                }
21191            }
21192        });
21193
21194        let mut txn_a = db.begin();
21195        txn_a.put("seq_t", vec![(0, Value::Null)]).unwrap();
21196        let a_id = txn_a.txn_id();
21197        // The stage-time allocation already holds the barrier.
21198        assert!(
21199            db.lock_manager().holds(a_id, &barrier_key),
21200            "sequence allocation takes the barrier at stage time"
21201        );
21202        let (a_tx, a_rx) = std::sync::mpsc::channel();
21203        std::thread::scope(|scope| {
21204            scope.spawn(|| {
21205                a_tx.send(txn_a.commit()).unwrap();
21206            });
21207            entered.wait();
21208            assert!(
21209                db.lock_manager().holds(a_id, &barrier_key),
21210                "the barrier is held through the commit"
21211            );
21212            resume.wait();
21213            assert!(a_rx.recv().unwrap().is_ok());
21214        });
21215        assert!(
21216            !db.lock_manager().holds(a_id, &barrier_key),
21217            "the barrier releases when the commit ends"
21218        );
21219    }
21220
21221    #[test]
21222    fn fk_wait_for_cycle_surfaces_deadlock_victim() {
21223        let dir = tempfile::tempdir().unwrap();
21224        let db = Database::create(dir.path()).unwrap();
21225        db.create_table("parent", parent_schema()).unwrap();
21226        db.create_table("child", child_schema()).unwrap();
21227        let mut seed = db.begin();
21228        seed.put("parent", vec![(0, Value::Int64(1))]).unwrap();
21229        seed.put("parent", vec![(0, Value::Int64(2))]).unwrap();
21230        seed.commit().unwrap();
21231        let (rid1, rid2) = {
21232            let handle = db.table("parent").unwrap();
21233            let table = handle.lock();
21234            let rid = |pk: i64| {
21235                table
21236                    .lookup_pk(&Value::Int64(pk).encode_key())
21237                    .expect("seeded parent row")
21238            };
21239            (rid(1), rid(2))
21240        };
21241
21242        // The hook fires after every successful FK lock acquisition; park the
21243        // first two (A's and B's Exclusive delete-side claims) so both are
21244        // held before either transaction attempts its Shared insert-side
21245        // claim — a deterministic wait-for cycle.
21246        let rendezvous = Arc::new(std::sync::Barrier::new(2));
21247        let calls = Arc::new(AtomicUsize::new(0));
21248        db.__set_fk_lock_hook({
21249            let rendezvous = Arc::clone(&rendezvous);
21250            let calls = Arc::clone(&calls);
21251            move || {
21252                if calls.fetch_add(1, Ordering::SeqCst) < 2 {
21253                    rendezvous.wait();
21254                }
21255            }
21256        });
21257
21258        // A: delete parent 1 (X fk:1), insert child → parent 2 (S fk:2).
21259        // B: delete parent 2 (X fk:2), insert child → parent 1 (S fk:1).
21260        let mut txn_a = db.begin();
21261        txn_a.delete("parent", rid1).unwrap();
21262        txn_a
21263            .put("child", vec![(0, Value::Int64(100)), (1, Value::Int64(2))])
21264            .unwrap();
21265        let mut txn_b = db.begin();
21266        txn_b.delete("parent", rid2).unwrap();
21267        txn_b
21268            .put("child", vec![(0, Value::Int64(101)), (1, Value::Int64(1))])
21269            .unwrap();
21270        let b_id = txn_b.txn_id();
21271
21272        let (a_tx, a_rx) = std::sync::mpsc::channel();
21273        let (b_tx, b_rx) = std::sync::mpsc::channel();
21274        std::thread::scope(|scope| {
21275            scope.spawn(|| {
21276                a_tx.send(txn_a.commit()).unwrap();
21277            });
21278            scope.spawn(|| {
21279                b_tx.send(txn_b.commit()).unwrap();
21280            });
21281            let a_result = a_rx.recv().unwrap();
21282            let b_result = b_rx.recv().unwrap();
21283            assert!(
21284                a_result.is_ok(),
21285                "the survivor commits once the victim releases: {a_result:?}"
21286            );
21287            match b_result {
21288                Err(MongrelError::Deadlock { victim, .. }) => {
21289                    assert_eq!(victim, b_id, "the youngest transaction is the victim");
21290                }
21291                other => panic!("the victim must surface a deadlock, got {other:?}"),
21292            }
21293        });
21294        // No phantom holds survive the victim's release.
21295        let fk_key = |table: &str, pk: i64| {
21296            let table_id = db.table_id(table).unwrap();
21297            let mut key = b"fk:".to_vec();
21298            key.extend_from_slice(&Value::Int64(pk).encode_key());
21299            LockKey::key(table_id, key)
21300        };
21301        assert!(!db.lock_manager().holds(b_id, &fk_key("parent", 2)));
21302        assert!(!db.lock_manager().holds(b_id, &fk_key("parent", 1)));
21303    }
21304
21305    #[test]
21306    fn locks_release_after_commit_rollback_and_failed_commit() {
21307        let dir = tempfile::tempdir().unwrap();
21308        let db = Database::create(dir.path()).unwrap();
21309        let table_id = db.create_table("seq_t", auto_inc_schema()).unwrap();
21310        let barrier_key = LockKey::sequence_barrier(format!("auto_inc:{table_id}").as_str());
21311
21312        // Successful commit: the stage-time barrier releases with the commit.
21313        let mut txn = db.begin();
21314        txn.put("seq_t", vec![(0, Value::Null)]).unwrap();
21315        let committed_id = txn.txn_id();
21316        txn.commit().unwrap();
21317        assert!(!db.lock_manager().holds(committed_id, &barrier_key));
21318
21319        // Rollback: the stage-time barrier releases with the drop.
21320        let mut txn = db.begin();
21321        txn.put("seq_t", vec![(0, Value::Null)]).unwrap();
21322        let rolled_back_id = txn.txn_id();
21323        assert!(db.lock_manager().holds(rolled_back_id, &barrier_key));
21324        txn.rollback();
21325        assert!(
21326            !db.lock_manager().holds(rolled_back_id, &barrier_key),
21327            "rollback must not leave phantom holds"
21328        );
21329
21330        // Failed commit (declared-unique violation): the claims release with
21331        // the error.
21332        db.create_table("users", unique_schema()).unwrap();
21333        let users_id = db.table_id("users").unwrap();
21334        let mut seed = db.begin();
21335        seed.put(
21336            "users",
21337            vec![(0, Value::Int64(1)), (1, Value::Bytes(b"a@x".to_vec()))],
21338        )
21339        .unwrap();
21340        seed.commit().unwrap();
21341        let mut txn = db.begin();
21342        // A different primary key but the same declared-unique email: the
21343        // Phase B unique check rejects this commit.
21344        txn.put(
21345            "users",
21346            vec![(0, Value::Int64(2)), (1, Value::Bytes(b"a@x".to_vec()))],
21347        )
21348        .unwrap();
21349        let failed_id = txn.txn_id();
21350        let result = txn.commit();
21351        assert!(matches!(result, Err(MongrelError::Conflict(_))));
21352        assert!(
21353            !db.lock_manager()
21354                .holds(failed_id, &pk_lock_key(users_id, 2)),
21355            "a failed commit must not leave phantom holds"
21356        );
21357    }
21358}
21359
21360#[cfg(test)]
21361mod lifecycle_tests {
21362    use super::*;
21363
21364    fn int_pk_schema() -> Schema {
21365        Schema {
21366            columns: vec![ColumnDef {
21367                id: 1,
21368                name: "id".into(),
21369                ty: TypeId::Int64,
21370                flags: crate::schema::ColumnFlags::empty()
21371                    .with(crate::schema::ColumnFlags::PRIMARY_KEY),
21372                default_value: None,
21373                embedding_source: None,
21374            }],
21375            ..Schema::default()
21376        }
21377    }
21378
21379    #[test]
21380    fn poisoned_core_rejects_operations_with_typed_errors() {
21381        let dir = tempfile::tempdir().unwrap();
21382        let db = Database::create(dir.path()).unwrap();
21383        db.create_table("t", int_pk_schema()).unwrap();
21384        assert_eq!(db.lifecycle_state(), crate::core::LifecycleState::Open);
21385
21386        // Drive the exact two-state poison the fsync-error sites set
21387        // (write-path flag + lifecycle transition), without process-global
21388        // fault injection, which would leak into parallel tests. The fsync
21389        // site itself is covered end-to-end in tests/lifecycle_poison.rs.
21390        db.poisoned.store(true, Ordering::Relaxed);
21391        db.lifecycle.poison();
21392        assert_eq!(db.lifecycle_state(), crate::core::LifecycleState::Poisoned);
21393
21394        // Guarded operations without their own write-path poison check reject
21395        // at admission with the lifecycle Conflict...
21396        let error = db.gc().unwrap_err();
21397        assert!(
21398            matches!(error, MongrelError::Conflict(_)),
21399            "gc must reject on a poisoned core: {error:?}"
21400        );
21401        let error = db.compact().unwrap_err();
21402        assert!(
21403            matches!(error, MongrelError::Conflict(_)),
21404            "compact must reject on a poisoned core: {error:?}"
21405        );
21406        assert!(db.operation_guard().is_err());
21407        // ...while paths that already checked the write-path flag keep their
21408        // legacy error.
21409        let error = db.create_table("t2", int_pk_schema()).unwrap_err();
21410        assert!(
21411            error.to_string().contains("database poisoned"),
21412            "the legacy poison error still wins where it existed: {error:?}"
21413        );
21414        let mut txn = db.begin();
21415        txn.put("t", vec![(1, Value::Int64(2))]).unwrap();
21416        assert!(txn
21417            .commit()
21418            .unwrap_err()
21419            .to_string()
21420            .contains("database poisoned"));
21421    }
21422
21423    #[test]
21424    fn shutdown_waits_for_operation_guards_to_drain() {
21425        let dir = tempfile::tempdir().unwrap();
21426        let db = Arc::new(Database::create(dir.path()).unwrap());
21427        db.create_table("t", int_pk_schema()).unwrap();
21428        // The guard holds the lifecycle's Arc, not the database's, so the
21429        // exclusive-owner shutdown can proceed to its drain step below.
21430        let guard = db.operation_guard().unwrap();
21431        let (started_tx, started_rx) = std::sync::mpsc::channel();
21432        let (done_tx, done_rx) = std::sync::mpsc::channel();
21433        let shutdown_thread = std::thread::spawn(move || {
21434            started_tx.send(()).unwrap();
21435            let result = db.shutdown();
21436            let _ = done_tx.send(result);
21437        });
21438        started_rx.recv().unwrap();
21439        std::thread::sleep(std::time::Duration::from_millis(100));
21440        assert!(
21441            done_rx.try_recv().is_err(),
21442            "shutdown must wait for the outstanding guard to drain"
21443        );
21444        drop(guard);
21445        shutdown_thread.join().unwrap();
21446        assert!(
21447            done_rx.recv().unwrap().is_ok(),
21448            "shutdown completes once the guard drops"
21449        );
21450    }
21451}
21452
21453#[cfg(test)]
21454mod commit_ts_ledger_tests {
21455    use super::*;
21456    use crate::memtable::Row;
21457
21458    fn int_pk_schema() -> Schema {
21459        Schema {
21460            columns: vec![ColumnDef {
21461                id: 1,
21462                name: "id".into(),
21463                ty: TypeId::Int64,
21464                flags: crate::schema::ColumnFlags::empty()
21465                    .with(crate::schema::ColumnFlags::PRIMARY_KEY),
21466                default_value: None,
21467                embedding_source: None,
21468            }],
21469            ..Schema::default()
21470        }
21471    }
21472
21473    fn commit_one(db: &Database) -> (Epoch, mongreldb_types::hlc::HlcTimestamp) {
21474        let mut txn = db.begin();
21475        let handle = txn.state_handle();
21476        txn.put("t", vec![(1, Value::Int64(1))]).unwrap();
21477        let epoch = txn.commit().unwrap();
21478        let crate::txn::TransactionState::Committed(receipt) = handle.state() else {
21479            panic!("expected Committed, got {:?}", handle.state());
21480        };
21481        (epoch, receipt.commit_ts)
21482    }
21483
21484    #[test]
21485    fn commit_ts_for_epoch_returns_the_exact_receipt_within_one_open() {
21486        let dir = tempfile::tempdir().unwrap();
21487        let db = Database::create(dir.path()).unwrap();
21488        db.create_table("t", int_pk_schema()).unwrap();
21489
21490        let (epoch, commit_ts) = commit_one(&db);
21491        assert_eq!(db.commit_ts_for_epoch(epoch), Some(commit_ts));
21492        // An epoch no commit sealed misses (callers fall back).
21493        assert_eq!(db.commit_ts_for_epoch(Epoch(epoch.0 + 100)), None);
21494    }
21495
21496    #[test]
21497    fn commit_ts_for_epoch_survives_reopen_with_the_physical_component() {
21498        let dir = tempfile::tempdir().unwrap();
21499        let (epoch, commit_ts) = {
21500            let db = Database::create(dir.path()).unwrap();
21501            db.create_table("t", int_pk_schema()).unwrap();
21502            commit_one(&db)
21503        };
21504
21505        let db = Database::open(dir.path()).unwrap();
21506        let reconstructed = db
21507            .commit_ts_for_epoch(epoch)
21508            .expect("the durable WAL CommitTimestamp ledger reconstructs the epoch");
21509        assert_eq!(reconstructed.physical_micros, commit_ts.physical_micros);
21510        // The ledger byte format stores micros only (spec §8.1): the logical
21511        // counter and tiebreaker reconstruct as 0.
21512        assert_eq!(reconstructed.logical, 0);
21513        assert_eq!(reconstructed.node_tiebreaker, 0);
21514    }
21515
21516    #[test]
21517    fn recovery_ledger_keeps_only_newest_epochs_and_ignores_aborted_txns() {
21518        use crate::wal::Op;
21519        let records = vec![
21520            crate::wal::Record::new(Epoch(1), 7, Op::CommitTimestamp { unix_nanos: 1_000 }),
21521            crate::wal::Record::new(
21522                Epoch(2),
21523                7,
21524                Op::TxnCommit {
21525                    epoch: 41,
21526                    added_runs: vec![],
21527                },
21528            ),
21529            // No CommitTimestamp for txn 8: not reconstructible.
21530            crate::wal::Record::new(
21531                Epoch(3),
21532                8,
21533                Op::TxnCommit {
21534                    epoch: 42,
21535                    added_runs: vec![],
21536                },
21537            ),
21538            // Timestamp without a commit marker: aborted, not reconstructible.
21539            crate::wal::Record::new(Epoch(4), 9, Op::CommitTimestamp { unix_nanos: 9_000 }),
21540        ];
21541        let ledger = commit_ts_ledger_from_recovery(&records);
21542        assert_eq!(ledger.len(), 1);
21543        assert_eq!(
21544            ledger.get(&41),
21545            Some(&mongreldb_types::hlc::HlcTimestamp {
21546                physical_micros: 1,
21547                logical: 0,
21548                node_tiebreaker: 0,
21549            })
21550        );
21551    }
21552
21553    #[test]
21554    fn new_writes_always_have_some_commit_ts() {
21555        let dir = tempfile::tempdir().unwrap();
21556        let db = Database::create(dir.path()).unwrap();
21557        db.create_table("t", int_pk_schema()).unwrap();
21558        let mut txn = db.begin();
21559        let state = txn.state_handle();
21560        txn.put("t", vec![(1, Value::Int64(7))]).unwrap();
21561        let epoch = txn.commit().unwrap();
21562        let crate::txn::TransactionState::Committed(receipt) = state.state() else {
21563            panic!("expected Committed receipt");
21564        };
21565        let handle = db.table("t").unwrap();
21566        let table = handle.lock();
21567        // Product snapshots are HLC-pinned; epoch-only Snapshot::at hides
21568        // HLC-stamped versions by design (no dual authority).
21569        let (snap, _g) = db.snapshot();
21570        let rows = table.visible_rows(snap).expect("visible rows");
21571        assert_eq!(
21572            rows.len(),
21573            1,
21574            "committed put must be visible under HLC snapshot"
21575        );
21576        assert_eq!(rows[0].commit_ts, Some(receipt.commit_ts));
21577        assert_eq!(db.commit_ts_for_epoch(epoch), Some(receipt.commit_ts));
21578    }
21579
21580    #[test]
21581    fn same_transaction_identical_hlc_on_apply() {
21582        use mongreldb_types::ids::{ClusterId, DatabaseId, NodeId};
21583
21584        let dir = tempfile::tempdir().unwrap();
21585        let db = Database::create_cluster_replica(
21586            dir.path(),
21587            ClusterId::from_bytes([1; 16]),
21588            NodeId::from_bytes([2; 16]),
21589            DatabaseId::from_bytes([3; 16]),
21590        )
21591        .unwrap();
21592        db.apply_replicated_catalog_command(&crate::catalog_cmds::CatalogCommandRecord {
21593            version: crate::catalog_cmds::CATALOG_COMMAND_FORMAT_VERSION,
21594            catalog_version: 1,
21595            command: crate::catalog_cmds::CatalogCommand::CreateTable {
21596                name: "t".into(),
21597                schema: int_pk_schema(),
21598                created_epoch: 1,
21599            },
21600        })
21601        .unwrap();
21602        let table_id = db.table_id("t").unwrap();
21603        let commit_ts = mongreldb_types::hlc::HlcTimestamp {
21604            physical_micros: 42_000,
21605            logical: 3,
21606            node_tiebreaker: 9,
21607        };
21608        // Same decision HLC applied twice (two participants / two apply
21609        // calls) must stamp identical commit_ts on every row version.
21610        let staged = vec![StagedTxnWrite::Put {
21611            table_id,
21612            rows: bincode::serialize(&vec![
21613                Row::new(crate::RowId(1), Epoch(0)).with_column(1, Value::Int64(1))
21614            ])
21615            .unwrap(),
21616        }
21617        .encode()
21618        .unwrap()];
21619        assert!(db
21620            .apply_committed_transaction(1 << 63, commit_ts, &staged)
21621            .unwrap());
21622        let staged2 = vec![StagedTxnWrite::Put {
21623            table_id,
21624            rows: bincode::serialize(&vec![
21625                Row::new(crate::RowId(2), Epoch(0)).with_column(1, Value::Int64(2))
21626            ])
21627            .unwrap(),
21628        }
21629        .encode()
21630        .unwrap()];
21631        assert!(db
21632            .apply_committed_transaction((1 << 63) + 1, commit_ts, &staged2)
21633            .unwrap());
21634        let handle = db.table("t").unwrap();
21635        let table = handle.lock();
21636        let row1 = table
21637            .get(crate::RowId(1), Snapshot::unbounded())
21638            .expect("applied row 1");
21639        let row2 = table
21640            .get(crate::RowId(2), Snapshot::unbounded())
21641            .expect("applied row 2");
21642        assert_eq!(row1.commit_ts, Some(commit_ts));
21643        assert_eq!(row2.commit_ts, Some(commit_ts));
21644        assert_eq!(row1.commit_ts, row2.commit_ts);
21645        drop(table);
21646        // Latest applied epoch's ledger entry matches the shared decision HLC
21647        // physical component (logical/tiebreaker may be zero on recovery form).
21648        let epoch = db.visible_epoch();
21649        assert_eq!(
21650            db.commit_ts_for_epoch(epoch).map(|ts| ts.physical_micros),
21651            Some(commit_ts.physical_micros)
21652        );
21653    }
21654
21655    #[test]
21656    fn snapshot_hlc_hides_later_commit_ts_even_if_epoch_higher() {
21657        use mongreldb_types::hlc::HlcTimestamp;
21658        let early = HlcTimestamp {
21659            physical_micros: 100,
21660            logical: 0,
21661            node_tiebreaker: 1,
21662        };
21663        let late = HlcTimestamp {
21664            physical_micros: 200,
21665            logical: 0,
21666            node_tiebreaker: 1,
21667        };
21668        // Snapshot at early HLC with a high epoch budget still hides a later HLC.
21669        let snap = Snapshot::at_hlc(Epoch(99), early);
21670        assert!(!snap.observes_version(Epoch(1), Some(late)));
21671        assert!(snap.observes_version(Epoch(1), Some(early)));
21672        // Live Database snapshots are HLC-pinned.
21673        let dir = tempfile::tempdir().unwrap();
21674        let db = Database::create(dir.path()).unwrap();
21675        let (snap, _g) = db.snapshot();
21676        assert_ne!(
21677            snap.commit_ts,
21678            HlcTimestamp::ZERO,
21679            "Database::snapshot must pin live HLC via at_hlc"
21680        );
21681    }
21682
21683    #[test]
21684    fn hlc_stamped_row_visible_at_hlc_snapshot_not_epoch_only() {
21685        let dir = tempfile::tempdir().unwrap();
21686        let db = Database::create(dir.path()).unwrap();
21687        db.create_table("t", int_pk_schema()).unwrap();
21688        let (_epoch, commit_ts) = commit_one(&db);
21689        assert_ne!(commit_ts, mongreldb_types::hlc::HlcTimestamp::ZERO);
21690
21691        let handle = db.table("t").unwrap();
21692        let table = handle.lock();
21693        // (a) HLC-stamped row visible at an HLC-pinned snapshot.
21694        let hlc_snap = Snapshot::at_hlc(Epoch(u64::MAX), commit_ts);
21695        let rows = table.visible_rows(hlc_snap).expect("visible");
21696        assert_eq!(rows.len(), 1);
21697        assert_eq!(rows[0].commit_ts, Some(commit_ts));
21698        assert!(table.get(rows[0].row_id, hlc_snap).is_some());
21699
21700        // (b) Epoch-only snapshot still sees HLC-stamped rows by epoch (dual-model).
21701        let legacy = Snapshot::at(Epoch(u64::MAX));
21702        assert!(!legacy.uses_hlc_authority());
21703        assert_eq!(
21704            table.visible_rows(legacy).expect("visible").len(),
21705            1,
21706            "epoch pin sees HLC-stamped rows by epoch during dual-model migration"
21707        );
21708        assert!(table.get(rows[0].row_id, legacy).is_some());
21709    }
21710
21711    #[test]
21712    fn hlc_gc_floor_reports_named_sources() {
21713        let dir = tempfile::tempdir().unwrap();
21714        let db = Database::create(dir.path()).unwrap();
21715        db.create_table("t", int_pk_schema()).unwrap();
21716        let (epoch, commit_ts) = commit_one(&db);
21717
21718        // No pins: every HLC source is ZERO.
21719        let empty = db.hlc_gc_floor();
21720        assert_eq!(empty.floor(), mongreldb_types::hlc::HlcTimestamp::ZERO);
21721        assert_eq!(empty.sources().len(), 6);
21722
21723        // Pin via product snapshot (transaction source, epoch-backed).
21724        let (_snap, guard) = db.snapshot();
21725        let with_pin = db.hlc_gc_floor();
21726        // Projection succeeds only when the ledger has a stamp for the pin epoch.
21727        let projected = db.commit_ts_for_epoch(epoch);
21728        if let Some(ts) = projected {
21729            // Snapshot pins the *visible* watermark, which should match the commit.
21730            assert_eq!(with_pin.transaction_snapshot, ts);
21731            assert_eq!(with_pin.floor(), ts);
21732            assert_eq!(ts.physical_micros, commit_ts.physical_micros);
21733        } else {
21734            assert_eq!(
21735                with_pin.transaction_snapshot,
21736                mongreldb_types::hlc::HlcTimestamp::ZERO
21737            );
21738        }
21739        drop(guard);
21740    }
21741}
21742
21743#[cfg(test)]
21744mod stage2e_storage_mode_tests {
21745    use super::*;
21746    use crate::schema::{ColumnDef, ColumnFlags, Schema, TypeId};
21747    use crate::storage_mode::{StorageMode, STORAGE_MODE_FILENAME};
21748    use mongreldb_types::ids::{ClusterId, DatabaseId, NodeId};
21749
21750    fn identity(seed: u8) -> (ClusterId, NodeId, DatabaseId) {
21751        (
21752            ClusterId::from_bytes([seed; 16]),
21753            NodeId::from_bytes([seed + 1; 16]),
21754            DatabaseId::from_bytes([seed + 2; 16]),
21755        )
21756    }
21757
21758    fn marker(root: &Path) -> Option<StorageMode> {
21759        let durable = crate::durable_file::DurableRoot::open(root).unwrap();
21760        crate::storage_mode::read(&durable).unwrap()
21761    }
21762
21763    fn simple_schema() -> Schema {
21764        Schema {
21765            columns: vec![ColumnDef {
21766                id: 1,
21767                name: "id".into(),
21768                ty: TypeId::Int64,
21769                flags: ColumnFlags::empty().with(ColumnFlags::PRIMARY_KEY),
21770                default_value: None,
21771                embedding_source: None,
21772            }],
21773            ..Schema::default()
21774        }
21775    }
21776
21777    #[test]
21778    fn standalone_create_writes_marker_and_reopens() {
21779        let dir = tempfile::tempdir().unwrap();
21780        let root = dir.path().join("db");
21781        let db = Database::create(&root).unwrap();
21782        assert_eq!(marker(&root), Some(StorageMode::Standalone));
21783        assert_eq!(db.storage_mode().unwrap(), Some(StorageMode::Standalone));
21784        drop(db);
21785        let db = Database::open(&root).unwrap();
21786        assert_eq!(marker(&root), Some(StorageMode::Standalone));
21787        drop(db);
21788    }
21789
21790    #[test]
21791    fn legacy_database_without_marker_opens_and_gains_marker() {
21792        let dir = tempfile::tempdir().unwrap();
21793        let root = dir.path().join("db");
21794        let db = Database::create(&root).unwrap();
21795        drop(db);
21796        // Simulate a pre-marker database.
21797        std::fs::remove_file(root.join(META_DIR).join(STORAGE_MODE_FILENAME)).unwrap();
21798        assert_eq!(marker(&root), None);
21799        let db = Database::open(&root).unwrap();
21800        assert_eq!(marker(&root), Some(StorageMode::Standalone));
21801        drop(db);
21802    }
21803
21804    #[test]
21805    fn server_owned_standalone_opens_embedded() {
21806        let dir = tempfile::tempdir().unwrap();
21807        let root = dir.path().join("db");
21808        let db = Database::create(&root).unwrap();
21809        drop(db);
21810        let durable = crate::durable_file::DurableRoot::open(&root).unwrap();
21811        crate::storage_mode::rewrite(&durable, &StorageMode::ServerOwnedStandalone).unwrap();
21812        let db = Database::open(&root).unwrap();
21813        assert_eq!(marker(&root), Some(StorageMode::ServerOwnedStandalone));
21814        drop(db);
21815    }
21816
21817    #[test]
21818    fn cluster_replica_is_rejected_by_normal_opens() {
21819        let dir = tempfile::tempdir().unwrap();
21820        let root = dir.path().join("db");
21821        let (cluster_id, node_id, database_id) = identity(10);
21822        let db = Database::create_cluster_replica(&root, cluster_id, node_id, database_id).unwrap();
21823        assert_eq!(
21824            marker(&root),
21825            Some(StorageMode::ClusterReplica {
21826                cluster_id,
21827                node_id,
21828                database_id,
21829            })
21830        );
21831        drop(db);
21832
21833        let error = Database::open(&root).unwrap_err();
21834        let message = error.to_string();
21835        assert!(
21836            matches!(error, MongrelError::InvalidArgument(_)),
21837            "unexpected error: {message}"
21838        );
21839        assert!(message.contains("cluster node runtime"), "{message}");
21840        assert!(message.contains(&cluster_id.to_hex()), "{message}");
21841        assert!(message.contains(&node_id.to_hex()), "{message}");
21842        assert!(message.contains(&database_id.to_hex()), "{message}");
21843
21844        let error = Database::open_with_options(&root, OpenOptions::default()).unwrap_err();
21845        assert!(error.to_string().contains("cluster node runtime"));
21846        // The rejected opens never disturbed the marker.
21847        assert_eq!(
21848            marker(&root),
21849            Some(StorageMode::ClusterReplica {
21850                cluster_id,
21851                node_id,
21852                database_id,
21853            })
21854        );
21855    }
21856
21857    #[test]
21858    fn offline_validation_opens_cluster_replica_read_only() {
21859        let dir = tempfile::tempdir().unwrap();
21860        let root = dir.path().join("db");
21861        let (cluster_id, node_id, database_id) = identity(20);
21862        let db = Database::create_cluster_replica(&root, cluster_id, node_id, database_id).unwrap();
21863        drop(db);
21864
21865        let options = OpenOptions::default().with_offline_validation(true);
21866        let db = Database::open_with_options(&root, options).unwrap();
21867        assert!(db.is_read_only_replica());
21868        let error = db.create_table("t", simple_schema()).unwrap_err();
21869        assert!(matches!(error, MongrelError::ReadOnlyReplica));
21870        drop(db);
21871        // Offline validation leaves the marker exactly as found.
21872        assert_eq!(
21873            marker(&root),
21874            Some(StorageMode::ClusterReplica {
21875                cluster_id,
21876                node_id,
21877                database_id,
21878            })
21879        );
21880    }
21881
21882    #[test]
21883    fn cluster_runtime_open_requires_exact_identity() {
21884        let dir = tempfile::tempdir().unwrap();
21885        let root = dir.path().join("db");
21886        let (cluster_id, node_id, database_id) = identity(30);
21887        let db = Database::create_cluster_replica(&root, cluster_id, node_id, database_id).unwrap();
21888        drop(db);
21889
21890        // A non-ClusterReplica expectation is a caller error.
21891        let error = Database::open_cluster_replica(&root, &StorageMode::Standalone).unwrap_err();
21892        assert!(matches!(error, MongrelError::InvalidArgument(_)));
21893        // Wrong database identity fails closed.
21894        let wrong = StorageMode::ClusterReplica {
21895            cluster_id,
21896            node_id,
21897            database_id: DatabaseId::from_bytes([99; 16]),
21898        };
21899        let error = Database::open_cluster_replica(&root, &wrong).unwrap_err();
21900        assert!(error.to_string().contains("identity mismatch"), "{error}");
21901        // A legacy database without a marker is not a cluster replica.
21902        let legacy = dir.path().join("legacy");
21903        let legacy_db = Database::create(&legacy).unwrap();
21904        drop(legacy_db);
21905        let expected = StorageMode::ClusterReplica {
21906            cluster_id,
21907            node_id,
21908            database_id,
21909        };
21910        let error = Database::open_cluster_replica(&legacy, &expected).unwrap_err();
21911        assert!(error.to_string().contains("identity mismatch"), "{error}");
21912
21913        // The matching identity opens; user writes are rejected (writes
21914        // arrive through the replicated apply path only).
21915        let db = Database::open_cluster_replica(&root, &expected).unwrap();
21916        assert!(db.is_read_only_replica());
21917        let error = db.create_table("t", simple_schema()).unwrap_err();
21918        assert!(matches!(error, MongrelError::ReadOnlyReplica));
21919        drop(db);
21920    }
21921}
21922
21923#[cfg(test)]
21924mod stage2e_replicated_apply_tests {
21925    use super::*;
21926    use crate::catalog_cmds::{CatalogCommand, CatalogCommandRecord, CatalogDelta};
21927    use crate::memtable::{Row, Value};
21928    use crate::schema::{ColumnDef, ColumnFlags, Schema, TypeId};
21929    use crate::wal::{Op, Record};
21930    use mongreldb_types::ids::{ClusterId, DatabaseId, NodeId};
21931    use std::sync::Arc;
21932
21933    fn ids() -> (ClusterId, NodeId, DatabaseId) {
21934        (
21935            ClusterId::from_bytes([1; 16]),
21936            NodeId::from_bytes([2; 16]),
21937            DatabaseId::from_bytes([3; 16]),
21938        )
21939    }
21940
21941    fn expected_mode() -> crate::storage_mode::StorageMode {
21942        let (cluster_id, node_id, database_id) = ids();
21943        crate::storage_mode::StorageMode::ClusterReplica {
21944            cluster_id,
21945            node_id,
21946            database_id,
21947        }
21948    }
21949
21950    fn simple_schema() -> Schema {
21951        Schema {
21952            columns: vec![ColumnDef {
21953                id: 1,
21954                name: "id".into(),
21955                ty: TypeId::Int64,
21956                flags: ColumnFlags::empty().with(ColumnFlags::PRIMARY_KEY),
21957                default_value: None,
21958                embedding_source: None,
21959            }],
21960            ..Schema::default()
21961        }
21962    }
21963
21964    fn create_table_record(name: &str, catalog_version: u64) -> CatalogCommandRecord {
21965        CatalogCommandRecord {
21966            version: crate::catalog_cmds::CATALOG_COMMAND_FORMAT_VERSION,
21967            catalog_version,
21968            command: CatalogCommand::CreateTable {
21969                name: name.to_string(),
21970                schema: simple_schema(),
21971                created_epoch: 1,
21972            },
21973        }
21974    }
21975
21976    fn put_records(txn_id: u64, table_id: u64, epoch: u64, values: &[i64]) -> Vec<Record> {
21977        let rows: Vec<Row> = values
21978            .iter()
21979            .map(|value| {
21980                // Distinct row ids per value so batches never overwrite each
21981                // other's MVCC versions.
21982                Row::new(crate::RowId(*value as u64), Epoch(epoch))
21983                    .with_column(1, Value::Int64(*value))
21984            })
21985            .collect();
21986        vec![
21987            Record::new(
21988                Epoch(0),
21989                txn_id,
21990                Op::Put {
21991                    table_id,
21992                    rows: bincode::serialize(&rows).unwrap(),
21993                },
21994            ),
21995            Record::new(Epoch(0), txn_id, Op::CommitTimestamp { unix_nanos: 1_000 }),
21996            Record::new(
21997                Epoch(0),
21998                txn_id,
21999                Op::TxnCommit {
22000                    epoch,
22001                    added_runs: Vec::new(),
22002                },
22003            ),
22004        ]
22005    }
22006
22007    fn visible_ids(db: &Database, table: &str) -> Vec<i64> {
22008        let handle = db.table(table).unwrap();
22009        // P0.5: HLC-stamped versions require an HLC-pinned snapshot.
22010        let snap = db.visible_snapshot();
22011        let rows = handle.lock().visible_rows(snap).unwrap();
22012        let mut values: Vec<i64> = rows
22013            .iter()
22014            .map(|row| match row.columns.get(&1) {
22015                Some(Value::Int64(value)) => *value,
22016                other => panic!("unexpected column: {other:?}"),
22017            })
22018            .collect();
22019        values.sort_unstable();
22020        values
22021    }
22022
22023    #[test]
22024    fn catalog_command_mounts_table_and_replays_as_noop() {
22025        let dir = tempfile::tempdir().unwrap();
22026        let (cluster_id, node_id, database_id) = ids();
22027        let db =
22028            Database::create_cluster_replica(dir.path(), cluster_id, node_id, database_id).unwrap();
22029
22030        let record = create_table_record("items", 1);
22031        let delta = db.apply_replicated_catalog_command(&record).unwrap();
22032        assert!(matches!(delta, CatalogDelta::TableCreated { .. }));
22033        assert_eq!(db.table_names(), vec!["items".to_string()]);
22034        assert_eq!(db.catalog_version(), 1);
22035
22036        // Idempotent replay of the same record.
22037        let delta = db.apply_replicated_catalog_command(&record).unwrap();
22038        assert!(matches!(delta, CatalogDelta::NoOp));
22039        assert_eq!(db.table_names().len(), 1);
22040        drop(db);
22041
22042        // The command was checkpointed: the table survives reopen.
22043        let db = Database::open_cluster_replica(dir.path(), &expected_mode()).unwrap();
22044        assert_eq!(db.table_names(), vec!["items".to_string()]);
22045        assert_eq!(db.catalog_version(), 1);
22046    }
22047
22048    #[test]
22049    fn records_apply_rows_and_skip_replays_across_restart() {
22050        let dir = tempfile::tempdir().unwrap();
22051        let (cluster_id, node_id, database_id) = ids();
22052        let db =
22053            Database::create_cluster_replica(dir.path(), cluster_id, node_id, database_id).unwrap();
22054        db.apply_replicated_catalog_command(&create_table_record("items", 1))
22055            .unwrap();
22056
22057        let records = put_records(1, 0, 2, &[10, 20, 30]);
22058        assert!(db.apply_replicated_records(&records).unwrap());
22059        assert_eq!(visible_ids(&db, "items"), vec![10, 20, 30]);
22060        assert_eq!(db.visible_epoch(), Epoch(2));
22061
22062        // Crash-window redelivery of the same committed transaction is a
22063        // side-effect-free replay.
22064        assert!(!db.apply_replicated_records(&records).unwrap());
22065        assert_eq!(visible_ids(&db, "items"), vec![10, 20, 30]);
22066
22067        // A later transaction at a higher epoch still applies.
22068        let later = put_records(2, 0, 3, &[40]);
22069        assert!(db.apply_replicated_records(&later).unwrap());
22070        assert_eq!(visible_ids(&db, "items"), vec![10, 20, 30, 40]);
22071        let db = Arc::new(db);
22072        db.shutdown().unwrap();
22073
22074        // Restart: the local WAL replays the applied rows, and the state
22075        // machine's redelivery is recognized as a replay — no double-apply.
22076        let db = Database::open_cluster_replica(dir.path(), &expected_mode()).unwrap();
22077        assert_eq!(visible_ids(&db, "items"), vec![10, 20, 30, 40]);
22078        assert!(!db.apply_replicated_records(&later).unwrap());
22079        assert!(!db.apply_replicated_records(&records).unwrap());
22080        assert_eq!(visible_ids(&db, "items"), vec![10, 20, 30, 40]);
22081    }
22082
22083    #[test]
22084    fn spilled_run_commits_fail_closed_this_wave() {
22085        let dir = tempfile::tempdir().unwrap();
22086        let (cluster_id, node_id, database_id) = ids();
22087        let db =
22088            Database::create_cluster_replica(dir.path(), cluster_id, node_id, database_id).unwrap();
22089        db.apply_replicated_catalog_command(&create_table_record("items", 1))
22090            .unwrap();
22091        let mut records = put_records(1, 0, 2, &[10]);
22092        let Some(Op::TxnCommit { added_runs, .. }) = records.last_mut().map(|r| &mut r.op) else {
22093            panic!("put_records ends in TxnCommit");
22094        };
22095        added_runs.push(crate::wal::AddedRun {
22096            table_id: 0,
22097            run_id: 7,
22098            row_count: 1,
22099            level: 0,
22100            min_row_id: 1,
22101            max_row_id: 1,
22102            content_hash: [0; 32],
22103        });
22104        let error = db.apply_replicated_records(&records).unwrap_err();
22105        assert!(
22106            error.to_string().contains("spilled-run"),
22107            "unexpected error: {error}"
22108        );
22109        assert_eq!(visible_ids(&db, "items"), Vec::<i64>::new());
22110    }
22111
22112    #[test]
22113    fn records_without_commit_marker_fail_closed() {
22114        let dir = tempfile::tempdir().unwrap();
22115        let (cluster_id, node_id, database_id) = ids();
22116        let db =
22117            Database::create_cluster_replica(dir.path(), cluster_id, node_id, database_id).unwrap();
22118        db.apply_replicated_catalog_command(&create_table_record("items", 1))
22119            .unwrap();
22120        let mut records = put_records(1, 0, 2, &[10]);
22121        records.pop(); // strip the TxnCommit
22122        let error = db.apply_replicated_records(&records).unwrap_err();
22123        assert!(matches!(error, MongrelError::InvalidArgument(_)));
22124        assert!(db.apply_replicated_records(&[]).is_err());
22125        assert_eq!(visible_ids(&db, "items"), Vec::<i64>::new());
22126    }
22127}
22128
22129#[cfg(test)]
22130mod stage2c_spill_translation_tests {
22131    use super::*;
22132    use crate::catalog_cmds::{CatalogCommand, CatalogCommandRecord};
22133    use crate::memtable::{Row, Value};
22134    use crate::schema::{ColumnDef, ColumnFlags, Schema, TypeId};
22135    use crate::wal::{Op, Record};
22136    use mongreldb_types::ids::{ClusterId, DatabaseId, NodeId};
22137
22138    fn simple_schema() -> Schema {
22139        Schema {
22140            columns: vec![ColumnDef {
22141                id: 1,
22142                name: "id".into(),
22143                ty: TypeId::Int64,
22144                flags: ColumnFlags::empty().with(ColumnFlags::PRIMARY_KEY),
22145                default_value: None,
22146                embedding_source: None,
22147            }],
22148            ..Schema::default()
22149        }
22150    }
22151
22152    fn create_table_record(name: &str, catalog_version: u64) -> CatalogCommandRecord {
22153        CatalogCommandRecord {
22154            version: crate::catalog_cmds::CATALOG_COMMAND_FORMAT_VERSION,
22155            catalog_version,
22156            command: CatalogCommand::CreateTable {
22157                name: name.to_string(),
22158                schema: simple_schema(),
22159                created_epoch: 1,
22160            },
22161        }
22162    }
22163
22164    fn visible_ids(db: &Database, table: &str) -> Vec<i64> {
22165        let handle = db.table(table).unwrap();
22166        // P0.5: HLC-stamped versions require an HLC-pinned snapshot.
22167        let snap = db.visible_snapshot();
22168        let rows = handle.lock().visible_rows(snap).unwrap();
22169        let mut values: Vec<i64> = rows
22170            .iter()
22171            .map(|row| match row.columns.get(&1) {
22172                Some(Value::Int64(value)) => *value,
22173                other => panic!("unexpected column: {other:?}"),
22174            })
22175            .collect();
22176        values.sort_unstable();
22177        values
22178    }
22179
22180    fn put_records(txn_id: u64, table_id: u64, epoch: u64, values: &[i64]) -> Vec<Record> {
22181        let rows: Vec<Row> = values
22182            .iter()
22183            .map(|value| {
22184                Row::new(crate::RowId(*value as u64), Epoch(epoch))
22185                    .with_column(1, Value::Int64(*value))
22186            })
22187            .collect();
22188        vec![
22189            Record::new(
22190                Epoch(0),
22191                txn_id,
22192                Op::Put {
22193                    table_id,
22194                    rows: bincode::serialize(&rows).unwrap(),
22195                },
22196            ),
22197            Record::new(Epoch(0), txn_id, Op::CommitTimestamp { unix_nanos: 1_000 }),
22198            Record::new(
22199                Epoch(0),
22200                txn_id,
22201                Op::TxnCommit {
22202                    epoch,
22203                    added_runs: Vec::new(),
22204                },
22205            ),
22206        ]
22207    }
22208
22209    fn added_run(
22210        table_id: u64,
22211        row_count: u64,
22212        min_row_id: u64,
22213        max_row_id: u64,
22214    ) -> crate::wal::AddedRun {
22215        crate::wal::AddedRun {
22216            table_id,
22217            run_id: 7,
22218            row_count,
22219            level: 0,
22220            min_row_id,
22221            max_row_id,
22222            content_hash: [0; 32],
22223        }
22224    }
22225
22226    /// Replays the shared WAL of `db` and returns every record of the one
22227    /// transaction whose commit marker links spilled runs.
22228    fn spilled_commit_records(db: &Database) -> Vec<Record> {
22229        let records = crate::wal::SharedWal::replay_with_dek(&db.root, None).unwrap();
22230        let txn_id = records
22231            .iter()
22232            .find_map(|record| match &record.op {
22233                Op::TxnCommit { added_runs, .. } if !added_runs.is_empty() => Some(record.txn_id),
22234                _ => None,
22235            })
22236            .expect("a spilled commit is present in the WAL");
22237        records
22238            .into_iter()
22239            .filter(|record| record.txn_id == txn_id)
22240            .collect()
22241    }
22242
22243    #[test]
22244    fn non_spilled_records_translate_byte_identical() {
22245        let records = put_records(1, 0, 2, &[10, 20, 30]);
22246        let translated = translate_records_for_replication(&records).unwrap();
22247        assert_eq!(
22248            bincode::serialize(&translated).unwrap(),
22249            bincode::serialize(&records).unwrap(),
22250            "a commit without spill links must pass through byte-identical"
22251        );
22252    }
22253
22254    #[test]
22255    fn translation_rejects_uncovered_or_malformed_spills() {
22256        // added_runs with no logical spill records at all: rejected.
22257        let mut records = put_records(1, 0, 2, &[10]);
22258        let Some(Op::TxnCommit { added_runs, .. }) = records.last_mut().map(|r| &mut r.op) else {
22259            panic!("put_records ends in TxnCommit");
22260        };
22261        added_runs.push(added_run(0, 1, 10, 10));
22262        let error = translate_records_for_replication(&records).unwrap_err();
22263        assert!(
22264            error.to_string().contains("no logical row records"),
22265            "unexpected error: {error}"
22266        );
22267
22268        // Coverage present but short of the linked row count: rejected.
22269        let mut records = put_records(1, 0, 2, &[10]);
22270        let spilled: Vec<Row> = (0..3_u64)
22271            .map(|value| {
22272                Row::new(crate::RowId(value), Epoch(2)).with_column(1, Value::Int64(value as i64))
22273            })
22274            .collect();
22275        records.insert(
22276            0,
22277            Record::new(
22278                Epoch(0),
22279                1,
22280                Op::SpilledRows {
22281                    table_id: 0,
22282                    rows: bincode::serialize(&spilled).unwrap(),
22283                },
22284            ),
22285        );
22286        let Some(Op::TxnCommit { added_runs, .. }) = records.last_mut().map(|r| &mut r.op) else {
22287            panic!("put_records ends in TxnCommit");
22288        };
22289        added_runs.push(added_run(0, 4, 0, 3));
22290        let error = translate_records_for_replication(&records).unwrap_err();
22291        assert!(
22292            error.to_string().contains("cover 3 rows"),
22293            "unexpected error: {error}"
22294        );
22295
22296        // An undecodable spill payload: rejected at propose time, never at apply.
22297        let mut records = put_records(1, 0, 2, &[10]);
22298        records.insert(
22299            0,
22300            Record::new(
22301                Epoch(0),
22302                1,
22303                Op::SpilledRows {
22304                    table_id: 0,
22305                    rows: vec![0xFF, 0x01, 0x02],
22306                },
22307            ),
22308        );
22309        let Some(Op::TxnCommit { added_runs, .. }) = records.last_mut().map(|r| &mut r.op) else {
22310            panic!("put_records ends in TxnCommit");
22311        };
22312        added_runs.push(added_run(0, 1, 0, 0));
22313        assert!(translate_records_for_replication(&records).is_err());
22314
22315        // Structural violations mirror the apply-side contract.
22316        assert!(translate_records_for_replication(&[]).is_err());
22317        let mut mixed = put_records(1, 0, 2, &[10]);
22318        mixed[0].txn_id = 99;
22319        assert!(translate_records_for_replication(&mixed).is_err());
22320        let mut no_commit = put_records(1, 0, 2, &[10]);
22321        no_commit.pop();
22322        assert!(translate_records_for_replication(&no_commit).is_err());
22323    }
22324
22325    #[test]
22326    fn spilled_commit_translates_to_logical_rows_and_applies_on_replica() {
22327        // A real standalone commit that spills (spec section 8.5).
22328        let leader_dir = tempfile::tempdir().unwrap();
22329        let leader = Database::create(leader_dir.path()).unwrap();
22330        leader.create_table("t", simple_schema()).unwrap();
22331        leader.set_spill_threshold(1);
22332        let table_id = leader.table_id("t").unwrap();
22333        let values: Vec<i64> = (0..60).collect();
22334        leader
22335            .transaction(|txn| {
22336                for value in &values {
22337                    txn.put("t", vec![(1, Value::Int64(*value))])?;
22338                }
22339                Ok(())
22340            })
22341            .unwrap();
22342        assert_eq!(visible_ids(&leader, "t"), values);
22343
22344        // The leader's own WAL keeps the spill shape: SpilledRows records
22345        // plus an added_runs commit marker.
22346        let records = spilled_commit_records(&leader);
22347        assert!(records
22348            .iter()
22349            .any(|record| matches!(record.op, Op::SpilledRows { .. })));
22350        let Some(Op::TxnCommit { added_runs, epoch }) = records.last().map(|r| &r.op) else {
22351            panic!("a commit sequence ends in TxnCommit");
22352        };
22353        assert!(!added_runs.is_empty());
22354        let commit_epoch = *epoch;
22355
22356        // Translation strips every run reference and keeps the rows as
22357        // logical puts; the input sequence is untouched.
22358        let translated = translate_records_for_replication(&records).unwrap();
22359        assert!(records
22360            .iter()
22361            .any(|record| matches!(record.op, Op::SpilledRows { .. })));
22362        let Some(Op::TxnCommit { added_runs, .. }) = records.last().map(|r| &r.op) else {
22363            panic!("a commit sequence ends in TxnCommit");
22364        };
22365        assert!(!added_runs.is_empty(), "input records must be unchanged");
22366        assert_eq!(translated.len(), records.len());
22367        assert!(translated
22368            .iter()
22369            .all(|record| !matches!(record.op, Op::SpilledRows { .. })));
22370        assert!(translated
22371            .iter()
22372            .any(|record| matches!(record.op, Op::Put { .. })));
22373        let Some(Op::TxnCommit { added_runs, epoch }) = translated.last().map(|r| &r.op) else {
22374            panic!("a commit sequence ends in TxnCommit");
22375        };
22376        assert!(added_runs.is_empty(), "no added_runs may reach a replica");
22377        assert_eq!(*epoch, commit_epoch);
22378
22379        // The translated payload applies on a replica with identical rows.
22380        let replica_dir = tempfile::tempdir().unwrap();
22381        let replica = Database::create_cluster_replica(
22382            replica_dir.path(),
22383            ClusterId::from_bytes([1; 16]),
22384            NodeId::from_bytes([2; 16]),
22385            DatabaseId::from_bytes([3; 16]),
22386        )
22387        .unwrap();
22388        replica
22389            .apply_replicated_catalog_command(&create_table_record("t", 1))
22390            .unwrap();
22391        assert_eq!(replica.table_id("t").unwrap(), table_id);
22392        assert!(replica.apply_replicated_records(&translated).unwrap());
22393        assert_eq!(visible_ids(&replica, "t"), values);
22394        assert_eq!(visible_ids(&replica, "t"), visible_ids(&leader, "t"));
22395
22396        // Standalone behavior is unchanged: the leader still recovers its
22397        // spilled commit by linking the run file.
22398        drop(leader);
22399        let leader = Database::open(leader_dir.path()).unwrap();
22400        assert_eq!(visible_ids(&leader, "t"), values);
22401    }
22402
22403    #[test]
22404    fn staged_txn_writes_validate_and_apply() {
22405        let dir = tempfile::tempdir().unwrap();
22406        let db = Database::create_cluster_replica(
22407            dir.path(),
22408            ClusterId::from_bytes([1; 16]),
22409            NodeId::from_bytes([2; 16]),
22410            DatabaseId::from_bytes([3; 16]),
22411        )
22412        .unwrap();
22413        db.apply_replicated_catalog_command(&create_table_record("t", 1))
22414            .unwrap();
22415        let table_id = db.table_id("t").unwrap();
22416
22417        // Malformed payloads and unmounted tables are rejected at prepare.
22418        assert!(db.validate_staged_txn_writes(&[vec![0xFF]]).is_err());
22419        let unknown_table = StagedTxnWrite::Put {
22420            table_id: 99,
22421            rows: bincode::serialize(&Vec::<Row>::new()).unwrap(),
22422        }
22423        .encode()
22424        .unwrap();
22425        assert!(db.validate_staged_txn_writes(&[unknown_table]).is_err());
22426        let good: Vec<Vec<u8>> = [10_i64, 20, 30]
22427            .iter()
22428            .map(|value| {
22429                let rows = vec![Row::new(crate::RowId(*value as u64), Epoch(0))
22430                    .with_column(1, Value::Int64(*value))];
22431                StagedTxnWrite::Put {
22432                    table_id,
22433                    rows: bincode::serialize(&rows).unwrap(),
22434                }
22435                .encode()
22436                .unwrap()
22437            })
22438            .collect();
22439        db.validate_staged_txn_writes(&good).unwrap();
22440
22441        // A committed resolution applies the staged writes; a delete
22442        // resolution removes them; both are replay-safe.
22443        let commit_ts = mongreldb_types::hlc::HlcTimestamp {
22444            physical_micros: 5_000,
22445            logical: 0,
22446            node_tiebreaker: 0,
22447        };
22448        assert!(db
22449            .apply_staged_txn_writes(1 << 63, &good, commit_ts)
22450            .unwrap());
22451        assert_eq!(visible_ids(&db, "t"), vec![10, 20, 30]);
22452        let delete = StagedTxnWrite::Delete {
22453            table_id,
22454            row_ids: vec![20],
22455        }
22456        .encode()
22457        .unwrap();
22458        assert!(db
22459            .apply_staged_txn_writes((1 << 63) + 1, &[delete], commit_ts)
22460            .unwrap());
22461        assert_eq!(visible_ids(&db, "t"), vec![10, 30]);
22462
22463        // Restart: the synthetic WAL transactions replay through the same
22464        // recovery path; the rows are durable.
22465        let db = Arc::new(db);
22466        db.shutdown().unwrap();
22467        let expected = crate::storage_mode::StorageMode::ClusterReplica {
22468            cluster_id: ClusterId::from_bytes([1; 16]),
22469            node_id: NodeId::from_bytes([2; 16]),
22470            database_id: DatabaseId::from_bytes([3; 16]),
22471        };
22472        let db = Database::open_cluster_replica(dir.path(), &expected).unwrap();
22473        assert_eq!(visible_ids(&db, "t"), vec![10, 30]);
22474    }
22475}