Skip to main content

mongreldb_core/
database.rs

1//! Multi-table `Database` container (spec §5, §6, §10).
2//!
3//! Owns the shared services — catalog, dual-counter epoch authority, shared
4//! raw/decoded page caches, snapshot-retention registry, and the DB-wide KEK —
5//! and mounts per-table [`Table`] engines under `tables/<id>/` that borrow them.
6//! P1 scope: per-table WALs remain (collapsed into one shared WAL in P2); the
7//! win here is one consistent commit clock across tables and one reopen path.
8
9// Online secondary-index DDL (create / drop / replace) as a child module so it
10// can reach private `DatabaseCore` fields while keeping the public surface on
11// [`Database`].
12#[path = "index_ddl.rs"]
13mod index_ddl;
14
15use crate::catalog::{self, Catalog, CatalogEntry, TableState, META_DEK_LEN};
16use crate::engine::{SharedCtx, Table};
17use crate::epoch::{Epoch, EpochAuthority, EpochGuard, MaintenanceReceipt, Snapshot};
18use crate::error::{MongrelError, Result};
19use crate::external_table::ExternalTableEntry;
20use crate::memtable::Value;
21use crate::procedure::{
22    ProcedureCallOutput, ProcedureCallResult, ProcedureCallRow, ProcedureCondition, ProcedureStep,
23    ProcedureValue, StoredProcedure,
24};
25use crate::retention::{OwnedSnapshotGuard, SnapshotGuard, SnapshotRegistry};
26use crate::rowid::RowId;
27use crate::schema::{AlterColumn, ColumnDef, ColumnFlags, Schema, TypeId};
28use crate::trigger::{
29    StoredTrigger, TriggerCondition, TriggerConfig, TriggerEntry, TriggerEvent, TriggerExpr,
30    TriggerRaiseAction, TriggerStep, TriggerTarget, TriggerTiming, TriggerValue,
31};
32use parking_lot::{Mutex, RwLock};
33use sha2::{Digest, Sha256};
34use std::collections::{BTreeSet, HashMap, HashSet, VecDeque};
35use std::io::{Read, Write};
36use std::path::{Path, PathBuf};
37use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, AtomicUsize, Ordering};
38use std::sync::Arc;
39use subtle::ConstantTimeEq;
40
41pub const TABLES_DIR: &str = "tables";
42pub const VTAB_DIR: &str = "_vtab";
43pub const META_DIR: &str = "_meta";
44pub const KEYS_FILENAME: &str = "keys";
45pub const KMS_KEY_FILENAME: &str = "kms_key.json";
46const MAX_KMS_KEY_ENVELOPE_BYTES: usize = 1024 * 1024;
47pub const HISTORY_RETENTION_FILENAME: &str = "history_retention";
48pub const CTAS_BUILD_TABLE_PREFIX: &str = "__mongreldb_ctas_build_";
49
50/// Sentinel `table_id` for `CheckIssue`s that concern the shared WAL rather
51/// than any table. `u64::MAX` is never allocated to a real table (the catalog
52/// mints ids from 0 upward), so [`Database::doctor`] can safely skip them.
53pub const WAL_TABLE_ID: u64 = u64::MAX;
54/// Sentinel `table_id` for `CheckIssue`s that concern external-table module
55/// state instead of an ordinary table.
56pub const EXTERNAL_TABLE_ID: u64 = u64::MAX - 1;
57
58fn advance_security_version(catalog: &mut Catalog) -> Result<()> {
59    catalog.security_version = catalog.security_version.checked_add(1).ok_or_else(|| {
60        MongrelError::Conflict("security catalog version space is exhausted".into())
61    })?;
62    Ok(())
63}
64
65type OpenLeaseId = u64;
66
67static DATABASE_OPEN_WAIT_COUNT: AtomicU64 = AtomicU64::new(0);
68static DATABASE_OPEN_FAILURE_COUNT: AtomicU64 = AtomicU64::new(0);
69
70#[derive(Clone, Copy, Debug, Eq, PartialEq)]
71pub struct DatabaseOpenMetrics {
72    pub lock_waits: u64,
73    pub failures: u64,
74}
75
76#[derive(Clone, Debug, Eq, Hash, PartialEq)]
77enum DatabaseOpenKey {
78    IntendedPath(PathBuf),
79    FileIdentity(crate::durable_file::DurableFileIdentity),
80}
81
82#[derive(Debug)]
83enum ProcessOpenState {
84    Opening { lease_id: OpenLeaseId },
85    Open { lease_id: OpenLeaseId },
86    Closing { lease_id: OpenLeaseId },
87}
88
89impl ProcessOpenState {
90    fn lease_id(&self) -> OpenLeaseId {
91        match self {
92            Self::Opening { lease_id } | Self::Open { lease_id } | Self::Closing { lease_id } => {
93                *lease_id
94            }
95        }
96    }
97}
98
99fn embedding_error(error: crate::embedding::EmbeddingError) -> MongrelError {
100    match error {
101        crate::embedding::EmbeddingError::LimitExceeded {
102            resource,
103            requested,
104            limit,
105        } => MongrelError::ResourceLimitExceeded {
106            resource,
107            requested,
108            limit,
109        },
110        crate::embedding::EmbeddingError::Execution(message) => {
111            if message.contains("deadline") {
112                MongrelError::DeadlineExceeded
113            } else {
114                MongrelError::Cancelled
115            }
116        }
117        error => MongrelError::Other(error.to_string()),
118    }
119}
120
121fn render_embedding_input(
122    schema: &Schema,
123    spec: &crate::embedding::GeneratedEmbeddingSpec,
124    cells: &[(u16, crate::memtable::Value)],
125) -> Result<String> {
126    let mut values = Vec::with_capacity(spec.source_columns.len());
127    for source_id in &spec.source_columns {
128        let column = schema
129            .columns
130            .iter()
131            .find(|column| column.id == *source_id)
132            .ok_or_else(|| MongrelError::Schema(format!("unknown embedding source {source_id}")))?;
133        let value = cells
134            .iter()
135            .find(|(id, _)| id == source_id)
136            .map(|(_, value)| embedding_input_value(value))
137            .transpose()?
138            .unwrap_or_default();
139        values.push((column.name.as_str(), value));
140    }
141    if spec.input_template.is_empty() {
142        return Ok(values
143            .into_iter()
144            .map(|(_, value)| value)
145            .collect::<Vec<_>>()
146            .join("\n"));
147    }
148    let mut rendered = spec.input_template.clone();
149    for (name, value) in values {
150        rendered = rendered.replace(&format!("{{{name}}}"), &value);
151    }
152    if rendered.contains('{') || rendered.contains('}') {
153        return Err(MongrelError::Schema(
154            "embedding input template contains an unknown placeholder".into(),
155        ));
156    }
157    Ok(rendered)
158}
159
160fn embedding_input_value(value: &crate::memtable::Value) -> Result<String> {
161    use crate::memtable::Value;
162    match value {
163        Value::Null => Ok(String::new()),
164        Value::Bool(value) => Ok(value.to_string()),
165        Value::Int64(value) => Ok(value.to_string()),
166        Value::Float64(value) => Ok(value.to_string()),
167        Value::Bytes(value) | Value::Json(value) => String::from_utf8(value.clone())
168            .map_err(|_| MongrelError::InvalidArgument("embedding source is not UTF-8".into())),
169        Value::Decimal(value) => Ok(value.to_string()),
170        Value::Interval {
171            months,
172            days,
173            nanos,
174        } => Ok(format!("{months}:{days}:{nanos}")),
175        Value::Uuid(value) => Ok(value.iter().map(|byte| format!("{byte:02x}")).collect()),
176        Value::Embedding(_) | Value::GeneratedEmbedding(_) => Err(MongrelError::InvalidArgument(
177            "embedding columns cannot be embedding text sources".into(),
178        )),
179    }
180}
181
182#[derive(Default)]
183struct ProcessOpenRegistry {
184    next_lease_id: OpenLeaseId,
185    entries: HashMap<DatabaseOpenKey, ProcessOpenState>,
186}
187
188fn process_open_registry() -> &'static Mutex<ProcessOpenRegistry> {
189    static REGISTRY: std::sync::OnceLock<Mutex<ProcessOpenRegistry>> = std::sync::OnceLock::new();
190    REGISTRY.get_or_init(|| Mutex::new(ProcessOpenRegistry::default()))
191}
192
193fn same_process_locked(path: &Path) -> MongrelError {
194    MongrelError::DatabaseLocked {
195        path: path.to_path_buf(),
196        message: "database is already open in this process; reuse the existing Arc<Database>"
197            .into(),
198    }
199}
200
201struct OpenReservation {
202    lease_id: OpenLeaseId,
203    keys: Vec<DatabaseOpenKey>,
204    committed: bool,
205}
206
207impl OpenReservation {
208    fn acquire(key: DatabaseOpenKey, display_path: &Path) -> Result<Self> {
209        let mut registry = process_open_registry().lock();
210        if registry.entries.contains_key(&key) {
211            DATABASE_OPEN_FAILURE_COUNT.fetch_add(1, Ordering::Relaxed);
212            return Err(same_process_locked(display_path));
213        }
214        registry.next_lease_id = registry.next_lease_id.checked_add(1).ok_or_else(|| {
215            MongrelError::Full("process database-open lease namespace exhausted".into())
216        })?;
217        let lease_id = registry.next_lease_id;
218        registry
219            .entries
220            .insert(key.clone(), ProcessOpenState::Opening { lease_id });
221        Ok(Self {
222            lease_id,
223            keys: vec![key],
224            committed: false,
225        })
226    }
227
228    fn into_lease(
229        mut self,
230        bootstrap_file: std::fs::File,
231        canonical_path: PathBuf,
232    ) -> ExclusiveDatabaseLease {
233        self.committed = true;
234        ExclusiveDatabaseLease {
235            lease_id: self.lease_id,
236            keys: std::mem::take(&mut self.keys),
237            bootstrap_file,
238            legacy_file: None,
239            canonical_path,
240            durable_root: None,
241            owner_pid: std::process::id(),
242            opened: false,
243        }
244    }
245}
246
247impl Drop for OpenReservation {
248    fn drop(&mut self) {
249        if self.committed {
250            return;
251        }
252        DATABASE_OPEN_FAILURE_COUNT.fetch_add(1, Ordering::Relaxed);
253        let mut registry = process_open_registry().lock();
254        for key in &self.keys {
255            if registry
256                .entries
257                .get(key)
258                .is_some_and(|state| state.lease_id() == self.lease_id)
259            {
260                registry.entries.remove(key);
261            }
262        }
263    }
264}
265
266struct ExclusiveDatabaseLease {
267    lease_id: OpenLeaseId,
268    keys: Vec<DatabaseOpenKey>,
269    bootstrap_file: std::fs::File,
270    legacy_file: Option<std::fs::File>,
271    canonical_path: PathBuf,
272    durable_root: Option<Arc<crate::durable_file::DurableRoot>>,
273    owner_pid: u32,
274    opened: bool,
275}
276
277impl ExclusiveDatabaseLease {
278    fn claim_root_identity(&mut self, root: &crate::durable_file::DurableRoot) -> Result<()> {
279        let key = DatabaseOpenKey::FileIdentity(root.file_identity()?);
280        if self.keys.contains(&key) {
281            return Ok(());
282        }
283        let mut registry = process_open_registry().lock();
284        if registry.entries.contains_key(&key) {
285            return Err(same_process_locked(&self.canonical_path));
286        }
287        registry.entries.insert(
288            key.clone(),
289            ProcessOpenState::Opening {
290                lease_id: self.lease_id,
291            },
292        );
293        self.keys.push(key);
294        Ok(())
295    }
296
297    fn mark_open(&mut self) -> Result<()> {
298        let mut registry = process_open_registry().lock();
299        if self.keys.iter().any(|key| {
300            registry
301                .entries
302                .get(key)
303                .is_none_or(|state| state.lease_id() != self.lease_id)
304        }) {
305            return Err(MongrelError::Conflict(
306                "database-open reservation changed during initialization".into(),
307            ));
308        }
309        for key in &self.keys {
310            registry.entries.insert(
311                key.clone(),
312                ProcessOpenState::Open {
313                    lease_id: self.lease_id,
314                },
315            );
316        }
317        self.opened = true;
318        Ok(())
319    }
320}
321
322impl Drop for ExclusiveDatabaseLease {
323    fn drop(&mut self) {
324        if std::process::id() != self.owner_pid {
325            return;
326        }
327        if !self.opened {
328            DATABASE_OPEN_FAILURE_COUNT.fetch_add(1, Ordering::Relaxed);
329        }
330        {
331            let mut registry = process_open_registry().lock();
332            for key in &self.keys {
333                if registry
334                    .entries
335                    .get(key)
336                    .is_some_and(|state| state.lease_id() == self.lease_id)
337                {
338                    registry.entries.insert(
339                        key.clone(),
340                        ProcessOpenState::Closing {
341                            lease_id: self.lease_id,
342                        },
343                    );
344                }
345            }
346        }
347        if let Some(file) = &self.legacy_file {
348            let _ = fs2::FileExt::unlock(file);
349        }
350        let _ = fs2::FileExt::unlock(&self.bootstrap_file);
351        let mut registry = process_open_registry().lock();
352        for key in &self.keys {
353            if registry
354                .entries
355                .get(key)
356                .is_some_and(|state| state.lease_id() == self.lease_id)
357            {
358                registry.entries.remove(key);
359            }
360        }
361    }
362}
363
364fn commit_prepare_checkpoint(
365    control: Option<&crate::ExecutionControl>,
366    index: usize,
367) -> Result<()> {
368    if index.is_multiple_of(256) {
369        if let Some(control) = control {
370            control.checkpoint()?;
371        }
372    }
373    Ok(())
374}
375
376fn finish_controlled_commit_attempt(
377    result: Result<Epoch>,
378    after_commit: &mut Option<&mut dyn FnMut(Option<Epoch>) -> Result<()>>,
379) -> Result<Epoch> {
380    let Some(after_commit) = after_commit.as_mut() else {
381        return result;
382    };
383    match result {
384        Ok(epoch) => match (**after_commit)(Some(epoch)) {
385            Ok(()) => Ok(epoch),
386            Err(error) => Err(MongrelError::DurableCommit {
387                epoch: epoch.0,
388                message: error.to_string(),
389            }),
390        },
391        Err(MongrelError::DurableCommit { epoch, message }) => {
392            let callback_error = (**after_commit)(Some(Epoch(epoch))).err();
393            Err(MongrelError::DurableCommit {
394                epoch,
395                message: callback_error
396                    .map(|error| format!("{message}; commit callback: {error}"))
397                    .unwrap_or(message),
398            })
399        }
400        Err(error) => match (**after_commit)(None) {
401            Ok(()) => Err(error),
402            Err(callback_error) => Err(MongrelError::Other(format!(
403                "{error}; commit callback: {callback_error}"
404            ))),
405        },
406    }
407}
408
409fn current_unix_nanos() -> u64 {
410    std::time::SystemTime::now()
411        .duration_since(std::time::UNIX_EPOCH)
412        .unwrap_or_default()
413        .as_nanos() as u64
414}
415
416fn read_encryption_salt(
417    root: &crate::durable_file::DurableRoot,
418) -> Result<[u8; crate::encryption::SALT_LEN]> {
419    let mut file = root
420        .open_regular(Path::new(META_DIR).join(KEYS_FILENAME))
421        .map_err(|error| MongrelError::NotFound(format!("encryption salt file: {error}")))?;
422    let length = file.metadata()?.len();
423    if length != crate::encryption::SALT_LEN as u64 {
424        return Err(MongrelError::Encryption(format!(
425            "invalid encryption salt length: got {length}, expected {}",
426            crate::encryption::SALT_LEN
427        )));
428    }
429    let mut salt = [0_u8; crate::encryption::SALT_LEN];
430    file.read_exact(&mut salt)?;
431    Ok(salt)
432}
433
434fn read_kms_key_envelope(
435    root: &crate::durable_file::DurableRoot,
436) -> Result<crate::security_hardening::KmsDatabaseKeyEnvelope> {
437    let file = root
438        .open_regular(Path::new(META_DIR).join(KMS_KEY_FILENAME))
439        .map_err(|error| MongrelError::NotFound(format!("KMS key envelope: {error}")))?;
440    let mut bytes = Vec::new();
441    file.take((MAX_KMS_KEY_ENVELOPE_BYTES + 1) as u64)
442        .read_to_end(&mut bytes)?;
443    if bytes.len() > MAX_KMS_KEY_ENVELOPE_BYTES {
444        return Err(MongrelError::Encryption(
445            "KMS key envelope exceeds 1 MiB".into(),
446        ));
447    }
448    serde_json::from_slice(&bytes)
449        .map_err(|error| MongrelError::Encryption(format!("invalid KMS key envelope: {error}")))
450}
451
452fn write_kms_key_envelope(
453    root: &Path,
454    envelope: &crate::security_hardening::KmsDatabaseKeyEnvelope,
455) -> Result<()> {
456    let bytes = serde_json::to_vec(envelope)
457        .map_err(|error| MongrelError::Encryption(format!("encode KMS key envelope: {error}")))?;
458    if bytes.len() > MAX_KMS_KEY_ENVELOPE_BYTES {
459        return Err(MongrelError::Encryption(
460            "KMS key envelope exceeds 1 MiB".into(),
461        ));
462    }
463    Ok(crate::durable_file::write_atomic(
464        &root.join(META_DIR).join(KMS_KEY_FILENAME),
465        &bytes,
466    )?)
467}
468
469fn unwrap_kms_database_key(
470    provider: &dyn crate::security_hardening::KeyManagementProvider,
471    envelope: &crate::security_hardening::KmsDatabaseKeyEnvelope,
472) -> Result<zeroize::Zeroizing<Vec<u8>>> {
473    if provider.provider_id() != envelope.provider_id {
474        return Err(MongrelError::Encryption(format!(
475            "KMS provider mismatch: database requires {:?}, got {:?}",
476            envelope.provider_id,
477            provider.provider_id()
478        )));
479    }
480    let key = provider
481        .unwrap_key(&envelope.wrapped_key)
482        .map_err(|error| MongrelError::Encryption(error.to_string()))?;
483    if key.len() != crate::encryption::DEK_LEN {
484        return Err(MongrelError::Encryption(format!(
485            "KMS returned {} key bytes, expected {}",
486            key.len(),
487            crate::encryption::DEK_LEN
488        )));
489    }
490    Ok(key)
491}
492
493fn create_kms_kek(
494    root: &Path,
495    provider: &dyn crate::security_hardening::KeyManagementProvider,
496    kms_key_id: &str,
497) -> Result<Arc<crate::encryption::Kek>> {
498    if kms_key_id.is_empty() {
499        return Err(MongrelError::InvalidArgument(
500            "KMS key id must not be empty".into(),
501        ));
502    }
503    let salt = crate::encryption::random_salt()?;
504    let mut raw_key = zeroize::Zeroizing::new([0_u8; crate::encryption::DEK_LEN]);
505    crate::encryption::fill_random(raw_key.as_mut())?;
506    let wrapped_key = provider
507        .wrap_key(kms_key_id, raw_key.as_ref())
508        .map_err(|error| MongrelError::Encryption(error.to_string()))?;
509    let envelope = crate::security_hardening::KmsDatabaseKeyEnvelope {
510        provider_id: provider.provider_id().to_owned(),
511        wrapped_key,
512    };
513    crate::durable_file::write_atomic(&root.join(META_DIR).join(KEYS_FILENAME), &salt)?;
514    write_kms_key_envelope(root, &envelope)?;
515    Ok(Arc::new(crate::encryption::Kek::from_raw_key(
516        raw_key.as_ref(),
517        &salt,
518    )?))
519}
520
521fn open_kms_kek(
522    root: &crate::durable_file::DurableRoot,
523    provider: &dyn crate::security_hardening::KeyManagementProvider,
524) -> Result<Arc<crate::encryption::Kek>> {
525    let salt = read_encryption_salt(root)?;
526    let envelope = read_kms_key_envelope(root)?;
527    let raw_key = unwrap_kms_database_key(provider, &envelope)?;
528    Ok(Arc::new(crate::encryption::Kek::from_raw_key(
529        raw_key.as_ref(),
530        &salt,
531    )?))
532}
533
534fn persist_key_rotation(
535    journal: &crate::security_hardening::KeyRotationJournal,
536    record: &crate::security_hardening::KeyRotationRecord,
537) -> Result<()> {
538    journal
539        .persist(record)
540        .map_err(|error| MongrelError::Encryption(error.to_string()))
541}
542
543fn fail_key_rotation<T>(
544    journal: &crate::security_hardening::KeyRotationJournal,
545    record: &mut crate::security_hardening::KeyRotationRecord,
546    error: impl ToString,
547) -> Result<T> {
548    let message = error.to_string();
549    record.fail(&message);
550    persist_key_rotation(journal, record)?;
551    Err(MongrelError::Encryption(message))
552}
553
554fn advance_key_rotation(
555    journal: &crate::security_hardening::KeyRotationJournal,
556    record: &mut crate::security_hardening::KeyRotationRecord,
557) -> Result<()> {
558    let now = std::time::SystemTime::now()
559        .duration_since(std::time::UNIX_EPOCH)
560        .unwrap_or_default()
561        .as_micros() as u64;
562    record
563        .advance(now)
564        .map_err(|error| MongrelError::Encryption(error.to_string()))?;
565    persist_key_rotation(journal, record)?;
566    let hook = match record.phase {
567        crate::security_hardening::KeyRotationPhase::WrappingNewKey => "kms.rotation.phase.1",
568        crate::security_hardening::KeyRotationPhase::DualRead => "kms.rotation.phase.2",
569        crate::security_hardening::KeyRotationPhase::Reencrypting => "kms.rotation.phase.3",
570        crate::security_hardening::KeyRotationPhase::Validating => "kms.rotation.phase.4",
571        crate::security_hardening::KeyRotationPhase::Published => "kms.rotation.phase.5",
572        crate::security_hardening::KeyRotationPhase::RetiringOldKey => "kms.rotation.phase.6",
573        crate::security_hardening::KeyRotationPhase::Succeeded => "kms.rotation.phase.7",
574        crate::security_hardening::KeyRotationPhase::Pending
575        | crate::security_hardening::KeyRotationPhase::Failed => return Ok(()),
576    };
577    mongreldb_fault::inject(hook).map_err(|error| MongrelError::Encryption(error.to_string()))
578}
579
580fn incremental_aggregate_cache_key(
581    table: &str,
582    conditions: &[crate::query::Condition],
583    column: Option<u16>,
584    agg: crate::engine::NativeAgg,
585    principal: Option<&crate::auth::Principal>,
586    security_version: u64,
587) -> u64 {
588    use std::hash::{Hash, Hasher};
589    let projection = column.as_ref().map(std::slice::from_ref);
590    let query_key = crate::query::canonical_query_key(conditions, projection, security_version);
591    let mut hasher = std::collections::hash_map::DefaultHasher::new();
592    table.hash(&mut hasher);
593    query_key.hash(&mut hasher);
594    match agg {
595        crate::engine::NativeAgg::Count => 0u8,
596        crate::engine::NativeAgg::Sum => 1,
597        crate::engine::NativeAgg::Min => 2,
598        crate::engine::NativeAgg::Max => 3,
599        crate::engine::NativeAgg::Avg => 4,
600    }
601    .hash(&mut hasher);
602    if let Some(principal) = principal {
603        principal.user_id.hash(&mut hasher);
604        principal.created_epoch.hash(&mut hasher);
605        principal.username.hash(&mut hasher);
606        principal.is_admin.hash(&mut hasher);
607        let mut roles = principal.roles.clone();
608        roles.sort_unstable();
609        roles.hash(&mut hasher);
610    }
611    hasher.finish()
612}
613
614fn read_history_retention(
615    root: &crate::durable_file::DurableRoot,
616    current_epoch: Epoch,
617) -> Result<(u64, Epoch)> {
618    const MAX_HISTORY_RETENTION_BYTES: u64 = 128;
619    let file = match root.open_regular(Path::new(META_DIR).join(HISTORY_RETENTION_FILENAME)) {
620        Ok(file) => file,
621        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
622            return Ok((0, current_epoch));
623        }
624        Err(error) => return Err(error.into()),
625    };
626    let length = file.metadata()?.len();
627    if length > MAX_HISTORY_RETENTION_BYTES {
628        return Err(MongrelError::ResourceLimitExceeded {
629            resource: "history retention bytes",
630            requested: usize::try_from(length).unwrap_or(usize::MAX),
631            limit: MAX_HISTORY_RETENTION_BYTES as usize,
632        });
633    }
634    let mut bytes = Vec::with_capacity(length as usize);
635    file.take(MAX_HISTORY_RETENTION_BYTES + 1)
636        .read_to_end(&mut bytes)?;
637    if bytes.len() as u64 != length {
638        return Err(MongrelError::Other(
639            "history retention length changed while reading".into(),
640        ));
641    }
642    let text = std::str::from_utf8(&bytes)
643        .map_err(|error| MongrelError::Other(format!("history retention encoding: {error}")))?;
644    let mut fields = text.split_whitespace();
645    let epochs = fields
646        .next()
647        .ok_or_else(|| MongrelError::Other("history retention file is empty".into()))?
648        .parse::<u64>()
649        .map_err(|error| MongrelError::Other(format!("history retention epochs: {error}")))?;
650    let start = fields
651        .next()
652        .ok_or_else(|| MongrelError::Other("history retention start is missing".into()))?
653        .parse::<u64>()
654        .map_err(|error| MongrelError::Other(format!("history retention start: {error}")))?;
655    if fields.next().is_some() || start > current_epoch.0 {
656        return Err(MongrelError::Other(
657            "history retention file has trailing fields or a future start epoch".into(),
658        ));
659    }
660    Ok((epochs, Epoch(start)))
661}
662
663fn write_history_retention<F>(
664    root: &Path,
665    epochs: u64,
666    start: Epoch,
667    after_publish: F,
668) -> Result<()>
669where
670    F: FnOnce(),
671{
672    let meta = root.join(META_DIR);
673    let path = meta.join(HISTORY_RETENTION_FILENAME);
674    let bytes = format!("{epochs} {}\n", start.0);
675    crate::durable_file::write_atomic_with_after(&path, bytes.as_bytes(), after_publish)?;
676    Ok(())
677}
678
679struct PreparedBackupDestination {
680    parent: crate::durable_file::DurableRoot,
681    destination_name: std::ffi::OsString,
682    destination_path: PathBuf,
683    stage_name: std::ffi::OsString,
684    stage: Option<Box<crate::durable_file::DurableRoot>>,
685}
686
687fn prepare_backup_destination(
688    source: &Path,
689    destination: &Path,
690) -> Result<PreparedBackupDestination> {
691    let destination_name = destination
692        .file_name()
693        .ok_or_else(|| MongrelError::InvalidArgument("invalid backup destination".into()))?
694        .to_os_string();
695    let requested_parent = destination
696        .parent()
697        .filter(|path| !path.as_os_str().is_empty())
698        .unwrap_or_else(|| Path::new("."));
699    crate::durable_file::create_directory_all(requested_parent)?;
700    let parent = crate::durable_file::DurableRoot::open(requested_parent)?;
701    prepare_backup_destination_in(source, &parent, &destination_name)
702}
703
704fn prepare_backup_destination_in(
705    source: &Path,
706    parent: &crate::durable_file::DurableRoot,
707    destination_name: &std::ffi::OsStr,
708) -> Result<PreparedBackupDestination> {
709    let source = source.canonicalize()?;
710    if parent.canonical_path().starts_with(&source) {
711        return Err(MongrelError::InvalidArgument(
712            "backup destination must not be inside the source database".into(),
713        ));
714    }
715    if parent.entry_exists(Path::new(&destination_name))? {
716        return Err(MongrelError::Conflict(format!(
717            "backup destination already exists: {}",
718            parent.canonical_path().join(destination_name).display()
719        )));
720    }
721    let mut stage_name = None;
722    for _ in 0..128 {
723        let mut nonce = [0_u8; 8];
724        crate::encryption::fill_random(&mut nonce)?;
725        let suffix = nonce
726            .iter()
727            .map(|byte| format!("{byte:02x}"))
728            .collect::<String>();
729        let name = std::ffi::OsString::from(format!(
730            ".{}.backup-stage-{}-{suffix}",
731            destination_name.to_string_lossy(),
732            std::process::id()
733        ));
734        match parent.create_directory_new(Path::new(&name)) {
735            Ok(()) => {
736                stage_name = Some(name);
737                break;
738            }
739            Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
740            Err(error) => return Err(error.into()),
741        }
742    }
743    let stage_name = stage_name
744        .ok_or_else(|| MongrelError::Conflict("could not allocate backup staging path".into()))?;
745    let stage = parent.open_directory(Path::new(&stage_name))?;
746    Ok(PreparedBackupDestination {
747        destination_path: parent.canonical_path().join(destination_name),
748        destination_name: destination_name.to_os_string(),
749        stage_name,
750        stage: Some(Box::new(stage)),
751        parent: parent.try_clone()?,
752    })
753}
754
755fn copy_backup_boundary(
756    source_root: &Path,
757    destination_root: &crate::durable_file::DurableRoot,
758    deferred_runs: &HashSet<PathBuf>,
759    copied: &mut Vec<PathBuf>,
760    control: Option<&crate::ExecutionControl>,
761) -> Result<()> {
762    let mut visited = 0;
763    crate::durable_file::walk_regular_files_nofollow(
764        source_root,
765        |relative, is_directory| {
766            if visited % 256 == 0 {
767                if let Some(control) = control {
768                    control.checkpoint()?;
769                }
770            }
771            visited += 1;
772            if backup_path_excluded(relative) {
773                return Ok(false);
774            }
775            if is_directory {
776                return Ok(true);
777            }
778            if deferred_runs.contains(relative) {
779                return Ok(false);
780            }
781            Ok(!(relative
782                .parent()
783                .and_then(Path::file_name)
784                .is_some_and(|parent| parent == "_runs")
785                && relative
786                    .extension()
787                    .is_some_and(|extension| extension == "sr")))
788        },
789        |relative| {
790            destination_root.create_directory_all(relative)?;
791            Ok(())
792        },
793        |relative, source| {
794            destination_root.copy_new_from(relative, source)?;
795            copied.push(relative.to_path_buf());
796            Ok(())
797        },
798    )
799}
800
801fn backup_path_excluded(relative: &Path) -> bool {
802    relative == Path::new("_meta/.lock")
803        || relative == Path::new("_meta/replica")
804        || relative == Path::new("_meta/repl_epoch")
805        || relative == Path::new(crate::backup::BACKUP_MANIFEST_PATH)
806        || relative.components().any(|component| {
807            matches!(component, std::path::Component::Normal(name) if name == "_cache" || name == "_txn" || name == "backup-pins")
808        })
809}
810
811#[derive(Debug, Clone)]
812pub enum ExternalTriggerWrite {
813    Insert {
814        table: String,
815        cells: Vec<(u16, Value)>,
816    },
817    UpdateByPk {
818        table: String,
819        pk: Value,
820        cells: Vec<(u16, Value)>,
821    },
822    DeleteByPk {
823        table: String,
824        pk: Value,
825    },
826}
827
828impl ExternalTriggerWrite {
829    fn table(&self) -> &str {
830        match self {
831            Self::Insert { table, .. }
832            | Self::UpdateByPk { table, .. }
833            | Self::DeleteByPk { table, .. } => table,
834        }
835    }
836}
837
838#[derive(Debug, Clone, PartialEq)]
839pub enum ExternalTriggerBaseWrite {
840    Put {
841        table: String,
842        cells: Vec<(u16, Value)>,
843    },
844    Delete {
845        table: String,
846        row_id: RowId,
847    },
848}
849
850#[derive(Debug, Clone, PartialEq)]
851pub struct ExternalTriggerWriteResult {
852    pub state: Vec<u8>,
853    pub base_writes: Vec<ExternalTriggerBaseWrite>,
854}
855
856impl ExternalTriggerWriteResult {
857    pub fn new(state: Vec<u8>) -> Self {
858        Self {
859            state,
860            base_writes: Vec::new(),
861        }
862    }
863}
864
865pub trait ExternalTriggerBridge: Send + Sync {
866    fn apply_trigger_external_write(
867        &self,
868        entry: &ExternalTableEntry,
869        base_state: Vec<u8>,
870        op: ExternalTriggerWrite,
871    ) -> Result<ExternalTriggerWriteResult>;
872}
873
874/// A pending uniform-epoch run written during a large transaction (spec §8.5).
875struct SpilledRun {
876    table_id: u64,
877    run_id: u128,
878    pending_path: PathBuf,
879    final_path: PathBuf,
880    rows: Vec<crate::memtable::Row>,
881    row_count: u64,
882    min_rid: u64,
883    max_rid: u64,
884    content_hash: [u8; 32],
885}
886
887const SPILLED_WAL_PAYLOAD_MAX_BYTES: usize = 24 * 1024 * 1024;
888const SPILLED_WAL_TOTAL_MAX_BYTES: usize = 256 * 1024 * 1024;
889
890fn encode_spilled_row_chunks(
891    rows: &[crate::memtable::Row],
892    total_bytes: &mut usize,
893    total_limit: usize,
894    control: Option<&crate::ExecutionControl>,
895) -> Result<Vec<Vec<u8>>> {
896    let mut output = Vec::new();
897    let mut start = 0;
898    while start < rows.len() {
899        // Bincode's sequence length prefix is a u64 with the workspace's
900        // fixed-int options. `serialized_size` computes exact row sizes
901        // without first allocating one transaction-sized buffer.
902        let mut estimated_bytes = std::mem::size_of::<u64>();
903        let mut end = start;
904        while end < rows.len() {
905            if end % 256 == 0 {
906                if let Some(control) = control {
907                    control.checkpoint()?;
908                }
909            }
910            let row_bytes =
911                usize::try_from(bincode::serialized_size(&rows[end])?).map_err(|_| {
912                    MongrelError::ResourceLimitExceeded {
913                        resource: "spilled WAL row bytes",
914                        requested: usize::MAX,
915                        limit: SPILLED_WAL_PAYLOAD_MAX_BYTES,
916                    }
917                })?;
918            let next_bytes = estimated_bytes.checked_add(row_bytes).ok_or(
919                MongrelError::ResourceLimitExceeded {
920                    resource: "spilled WAL row bytes",
921                    requested: usize::MAX,
922                    limit: SPILLED_WAL_PAYLOAD_MAX_BYTES,
923                },
924            )?;
925            if next_bytes > SPILLED_WAL_PAYLOAD_MAX_BYTES {
926                break;
927            }
928            estimated_bytes = next_bytes;
929            end += 1;
930        }
931        if end == start {
932            return Err(MongrelError::ResourceLimitExceeded {
933                resource: "spilled WAL row bytes",
934                requested: estimated_bytes.saturating_add(1),
935                limit: SPILLED_WAL_PAYLOAD_MAX_BYTES,
936            });
937        }
938        let payload = bincode::serialize(&rows[start..end])?;
939        if payload.len() > SPILLED_WAL_PAYLOAD_MAX_BYTES {
940            return Err(MongrelError::ResourceLimitExceeded {
941                resource: "spilled WAL row bytes",
942                requested: payload.len(),
943                limit: SPILLED_WAL_PAYLOAD_MAX_BYTES,
944            });
945        }
946        let requested = total_bytes.checked_add(payload.len()).unwrap_or(usize::MAX);
947        if requested > total_limit {
948            return Err(MongrelError::ResourceLimitExceeded {
949                resource: "spilled WAL transaction bytes",
950                requested,
951                limit: total_limit,
952            });
953        }
954        *total_bytes = requested;
955        output.push(payload);
956        start = end;
957    }
958    Ok(output)
959}
960
961#[cfg(test)]
962mod spilled_wal_encoding_tests {
963    use super::*;
964
965    #[test]
966    fn logical_spill_payload_has_a_total_bound() {
967        let rows = (0..4)
968            .map(|row_id| crate::memtable::Row {
969                row_id: crate::rowid::RowId(row_id),
970                committed_epoch: Epoch::ZERO,
971                columns: [(1, Value::Bytes(vec![0; 64]))].into_iter().collect(),
972                deleted: false,
973                commit_ts: None,
974            })
975            .collect::<Vec<_>>();
976        let mut total = 0;
977        let error = encode_spilled_row_chunks(&rows, &mut total, 32, None).unwrap_err();
978        assert!(matches!(
979            error,
980            MongrelError::ResourceLimitExceeded {
981                resource: "spilled WAL transaction bytes",
982                ..
983            }
984        ));
985    }
986}
987
988/// Move spill files to their final names before the WAL commit. Dropping this
989/// guard restores pending names while commit is still known not to have begun.
990/// It is disarmed immediately before the first WAL append, where the outcome
991/// can become ambiguous and recovery may need the final names.
992struct PreparedRunLinks {
993    links: Vec<(PathBuf, PathBuf)>,
994    armed: bool,
995}
996
997impl PreparedRunLinks {
998    fn prepare(spilled: &[SpilledRun]) -> Result<Self> {
999        let mut guard = Self {
1000            links: Vec::with_capacity(spilled.len()),
1001            armed: true,
1002        };
1003        for run in spilled {
1004            crate::durable_file::rename(&run.pending_path, &run.final_path)?;
1005            guard
1006                .links
1007                .push((run.pending_path.clone(), run.final_path.clone()));
1008        }
1009        Ok(guard)
1010    }
1011
1012    fn disarm(&mut self) {
1013        self.armed = false;
1014        for (pending, _) in &self.links {
1015            if let Some(parent) = pending.parent() {
1016                let _ = std::fs::remove_dir_all(parent);
1017            }
1018        }
1019    }
1020}
1021
1022impl Drop for PreparedRunLinks {
1023    fn drop(&mut self) {
1024        if !self.armed {
1025            return;
1026        }
1027        for (pending, final_path) in self.links.iter().rev() {
1028            let _ = std::fs::rename(final_path, pending);
1029        }
1030    }
1031}
1032
1033struct TableApplyBatch {
1034    table_id: u64,
1035    handle: TableHandle,
1036    ops: Vec<crate::txn::StagedOp>,
1037}
1038
1039#[derive(Debug, Clone)]
1040struct TriggerRowImage {
1041    columns: HashMap<u16, Value>,
1042}
1043
1044impl TriggerRowImage {
1045    fn from_row(row: crate::memtable::Row) -> Self {
1046        Self {
1047            columns: row.columns,
1048        }
1049    }
1050
1051    fn from_cells(cells: &[(u16, Value)]) -> Self {
1052        Self {
1053            columns: cells.iter().cloned().collect(),
1054        }
1055    }
1056}
1057
1058#[derive(Debug, Clone)]
1059struct WriteEvent {
1060    table: String,
1061    kind: TriggerEvent,
1062    old: Option<TriggerRowImage>,
1063    new: Option<TriggerRowImage>,
1064    changed_columns: Vec<u16>,
1065    op_indices: Vec<usize>,
1066    put_idx: Option<usize>,
1067    trigger_stack: Vec<String>,
1068}
1069
1070#[derive(Default)]
1071struct TriggerExpansion {
1072    before: Vec<(u64, crate::txn::Staged)>,
1073    before_stacks: Vec<Vec<String>>,
1074    before_external: Vec<ExternalTriggerWrite>,
1075    after: Vec<(u64, crate::txn::Staged)>,
1076    after_stacks: Vec<Vec<String>>,
1077    after_external: Vec<ExternalTriggerWrite>,
1078    ignored_indices: std::collections::BTreeSet<usize>,
1079}
1080
1081#[derive(Clone, PartialEq)]
1082struct TriggerCatalogBinding {
1083    triggers: Vec<TriggerEntry>,
1084    tables: Vec<(String, u64, u64)>,
1085    external_tables: Vec<(String, u64, u64)>,
1086}
1087
1088fn trigger_catalog_binding(catalog: &Catalog) -> Option<TriggerCatalogBinding> {
1089    let mut triggers = catalog
1090        .triggers
1091        .iter()
1092        .filter(|entry| entry.trigger.enabled)
1093        .cloned()
1094        .collect::<Vec<_>>();
1095    if triggers.is_empty() {
1096        return None;
1097    }
1098    triggers.sort_by(|left, right| left.trigger.name.cmp(&right.trigger.name));
1099    let mut tables = catalog
1100        .tables
1101        .iter()
1102        .filter(|entry| matches!(entry.state, TableState::Live))
1103        .map(|entry| (entry.name.clone(), entry.table_id, entry.schema.schema_id))
1104        .collect::<Vec<_>>();
1105    tables.sort_unstable();
1106    let mut external_tables = catalog
1107        .external_tables
1108        .iter()
1109        .map(|entry| {
1110            (
1111                entry.name.clone(),
1112                entry.created_epoch,
1113                entry.declared_schema.schema_id,
1114            )
1115        })
1116        .collect::<Vec<_>>();
1117    external_tables.sort_unstable();
1118    Some(TriggerCatalogBinding {
1119        triggers,
1120        tables,
1121        external_tables,
1122    })
1123}
1124
1125struct TriggerProgramOutput<'a> {
1126    added: &'a mut Vec<(u64, crate::txn::Staged)>,
1127    added_stacks: &'a mut Vec<Vec<String>>,
1128    added_external: &'a mut Vec<ExternalTriggerWrite>,
1129    ignored_indices: &'a mut std::collections::BTreeSet<usize>,
1130}
1131
1132#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1133enum TriggerProgramOutcome {
1134    Continue,
1135    Ignore,
1136}
1137
1138/// An integrity issue found by [`Database::check`] (spec §16).
1139#[derive(Debug, Clone)]
1140pub struct CheckIssue {
1141    pub table_id: u64,
1142    pub table_name: String,
1143    pub severity: String,
1144    pub description: String,
1145}
1146
1147/// One optimistic authorization snapshot for a complete scored read.
1148#[derive(Debug, Clone)]
1149pub struct AuthorizedReadSnapshot {
1150    pub table: String,
1151    pub table_snapshot: Snapshot,
1152    pub data_generation: u64,
1153    pub security_version: u64,
1154    pub allowed_row_ids: Option<HashSet<RowId>>,
1155}
1156
1157/// Exact table/security generation used by one successful authorized read.
1158#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1159pub struct AuthorizedReadStamp {
1160    pub table_id: u64,
1161    pub schema_id: u64,
1162    pub data_generation: u64,
1163    pub security_version: u64,
1164    pub snapshot: Snapshot,
1165}
1166
1167type RlsCacheKey = (String, u64, u64, String);
1168
1169/// Runtime statistics for the byte-bounded RLS candidate cache.
1170#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1171pub struct RlsCacheStats {
1172    pub entries: usize,
1173    pub bytes: usize,
1174    pub hits: u64,
1175    pub misses: u64,
1176    pub evictions: u64,
1177    pub build_nanos: u64,
1178    pub rows_evaluated: u64,
1179}
1180
1181const RLS_CACHE_MAX_BYTES: usize = 64 * 1024 * 1024;
1182const CDC_MAX_WAL_RECORDS: usize = 1_000_000;
1183const CDC_MAX_WAL_REPLAY_BYTES: usize = 256 * 1024 * 1024;
1184const CDC_MAX_EVENTS: usize = 100_000;
1185const CDC_MAX_ROWS: usize = 1_000_000;
1186const CDC_MAX_INLINE_PAYLOAD_BYTES: usize = 32 * 1024 * 1024;
1187const CDC_MAX_RETAINED_BYTES: usize = 256 * 1024 * 1024;
1188
1189fn charge_cdc_bytes(total: &mut usize, amount: usize, resource: &'static str) -> Result<()> {
1190    let requested = total.saturating_add(amount);
1191    if requested > CDC_MAX_RETAINED_BYTES {
1192        return Err(MongrelError::ResourceLimitExceeded {
1193            resource,
1194            requested,
1195            limit: CDC_MAX_RETAINED_BYTES,
1196        });
1197    }
1198    *total = requested;
1199    Ok(())
1200}
1201
1202fn cdc_row_storage_bytes(row: &crate::memtable::Row) -> usize {
1203    usize::try_from(row.estimated_bytes())
1204        .unwrap_or(usize::MAX)
1205        .saturating_add(std::mem::size_of::<crate::memtable::Row>())
1206}
1207
1208fn cdc_row_json_bytes(row: &crate::memtable::Row) -> usize {
1209    let value_slot = std::mem::size_of::<serde_json::Value>();
1210    row.columns.values().fold(512_usize, |bytes, value| {
1211        let values = match value {
1212            Value::Bytes(values) => values.len(),
1213            Value::Json(values) => values.len(),
1214            Value::Embedding(values) => values.len(),
1215            Value::GeneratedEmbedding(value) => value.vector.len(),
1216            _ => 1,
1217        };
1218        bytes.saturating_add(values.saturating_mul(value_slot))
1219    })
1220}
1221
1222fn cdc_rows_json_bytes(rows: &[crate::memtable::Row]) -> usize {
1223    rows.iter().fold(0_usize, |bytes, row| {
1224        bytes.saturating_add(cdc_row_json_bytes(row))
1225    })
1226}
1227
1228struct RlsCacheEntry {
1229    value: Arc<HashSet<RowId>>,
1230    bytes: usize,
1231    generation: u64,
1232}
1233
1234#[derive(Default)]
1235struct RlsCache {
1236    entries: HashMap<RlsCacheKey, RlsCacheEntry>,
1237    lru: BTreeSet<(u64, RlsCacheKey)>,
1238    next_generation: u64,
1239    bytes: usize,
1240    hits: u64,
1241    misses: u64,
1242    evictions: u64,
1243    build_nanos: u64,
1244    rows_evaluated: u64,
1245}
1246
1247impl RlsCache {
1248    fn get(&mut self, key: &RlsCacheKey) -> Option<Arc<HashSet<RowId>>> {
1249        let value = self.entries.get(key).map(|entry| Arc::clone(&entry.value));
1250        if value.is_some() {
1251            self.hits = self.hits.saturating_add(1);
1252            self.touch(key);
1253        } else {
1254            self.misses = self.misses.saturating_add(1);
1255        }
1256        value
1257    }
1258
1259    fn insert(&mut self, key: RlsCacheKey, value: Arc<HashSet<RowId>>) {
1260        let bytes = key
1261            .0
1262            .len()
1263            .saturating_add(key.3.len())
1264            .saturating_add(
1265                value
1266                    .capacity()
1267                    .saturating_mul(std::mem::size_of::<RowId>() * 3),
1268            )
1269            .saturating_add(std::mem::size_of::<RlsCacheKey>());
1270        if bytes > RLS_CACHE_MAX_BYTES {
1271            return;
1272        }
1273        if let Some(old) = self.entries.remove(&key) {
1274            self.bytes = self.bytes.saturating_sub(old.bytes);
1275            self.lru.remove(&(old.generation, key.clone()));
1276        }
1277        while self.bytes.saturating_add(bytes) > RLS_CACHE_MAX_BYTES {
1278            let Some((_, oldest)) = self.lru.pop_first() else {
1279                break;
1280            };
1281            if let Some(old) = self.entries.remove(&oldest) {
1282                self.bytes = self.bytes.saturating_sub(old.bytes);
1283                self.evictions = self.evictions.saturating_add(1);
1284            }
1285        }
1286        let generation = self.allocate_generation();
1287        self.bytes = self.bytes.saturating_add(bytes);
1288        self.lru.insert((generation, key.clone()));
1289        self.entries.insert(
1290            key,
1291            RlsCacheEntry {
1292                value,
1293                bytes,
1294                generation,
1295            },
1296        );
1297    }
1298
1299    fn touch(&mut self, key: &RlsCacheKey) {
1300        let Some(previous) = self.entries.get(key).map(|entry| entry.generation) else {
1301            return;
1302        };
1303        self.lru.remove(&(previous, key.clone()));
1304        let generation = self.allocate_generation();
1305        if let Some(entry) = self.entries.get_mut(key) {
1306            entry.generation = generation;
1307        }
1308        self.lru.insert((generation, key.clone()));
1309    }
1310
1311    fn allocate_generation(&mut self) -> u64 {
1312        if self.next_generation == u64::MAX {
1313            self.rebase_generations();
1314        }
1315        let generation = self.next_generation;
1316        self.next_generation += 1;
1317        generation
1318    }
1319
1320    fn rebase_generations(&mut self) {
1321        let ordered = std::mem::take(&mut self.lru);
1322        for (generation, (_, key)) in ordered.into_iter().enumerate() {
1323            let generation = generation as u64;
1324            if let Some(entry) = self.entries.get_mut(&key) {
1325                entry.generation = generation;
1326                self.lru.insert((generation, key));
1327            }
1328        }
1329        self.next_generation = self.lru.len() as u64;
1330    }
1331
1332    fn stats(&self) -> RlsCacheStats {
1333        RlsCacheStats {
1334            entries: self.entries.len(),
1335            bytes: self.bytes,
1336            hits: self.hits,
1337            misses: self.misses,
1338            evictions: self.evictions,
1339            build_nanos: self.build_nanos,
1340            rows_evaluated: self.rows_evaluated,
1341        }
1342    }
1343}
1344
1345#[cfg(test)]
1346mod rls_cache_tests {
1347    use super::*;
1348
1349    fn key(principal: &str) -> RlsCacheKey {
1350        ("table".into(), 1, 1, principal.into())
1351    }
1352
1353    fn rows(row_id: u64) -> Arc<HashSet<RowId>> {
1354        Arc::new(std::iter::once(RowId(row_id)).collect())
1355    }
1356
1357    #[test]
1358    fn hits_update_recency_without_growing_the_order_index() {
1359        let mut cache = RlsCache::default();
1360        let first = key("first");
1361        let second = key("second");
1362        cache.insert(first.clone(), rows(1));
1363        cache.insert(second.clone(), rows(2));
1364        assert_eq!(cache.lru.len(), cache.entries.len());
1365
1366        for _ in 0..1_000 {
1367            assert!(cache.get(&first).is_some());
1368        }
1369
1370        assert_eq!(cache.lru.len(), cache.entries.len());
1371        assert_eq!(
1372            cache.lru.first().map(|(_, candidate)| candidate),
1373            Some(&second)
1374        );
1375
1376        cache.insert(first.clone(), rows(3));
1377        assert_eq!(cache.lru.len(), cache.entries.len());
1378        let replacement = cache.get(&first).unwrap();
1379        assert_eq!(replacement.len(), 1);
1380        assert!(replacement.contains(&RowId(3)));
1381    }
1382}
1383
1384/// Mounted table with immutable, structurally shared scored-read generations.
1385#[derive(Clone)]
1386pub struct TableHandle {
1387    inner: TableHandleInner,
1388    generation_metrics: Arc<TableGenerationMetrics>,
1389}
1390
1391#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
1392pub struct TableGenerationStats {
1393    pub active_read_generations: usize,
1394    pub max_live_read_generations: usize,
1395    pub cow_clone_count: u64,
1396    pub cow_clone_nanos: u64,
1397    pub estimated_cow_clone_bytes: u64,
1398    pub writer_wait_nanos: u64,
1399}
1400
1401#[derive(Default)]
1402#[doc(hidden)]
1403pub struct TableGenerationMetrics {
1404    active_read_generations: AtomicUsize,
1405    max_live_read_generations: AtomicUsize,
1406    cow_clone_count: AtomicU64,
1407    cow_clone_nanos: AtomicU64,
1408    estimated_cow_clone_bytes: AtomicU64,
1409    writer_wait_nanos: AtomicU64,
1410}
1411
1412impl TableGenerationMetrics {
1413    fn activate(self: &Arc<Self>, table: Table) -> Arc<TableReadGeneration> {
1414        let active = self.active_read_generations.fetch_add(1, Ordering::Relaxed) + 1;
1415        self.max_live_read_generations
1416            .fetch_max(active, Ordering::Relaxed);
1417        Arc::new(TableReadGeneration {
1418            table,
1419            metrics: Arc::clone(self),
1420        })
1421    }
1422
1423    fn stats(&self) -> TableGenerationStats {
1424        TableGenerationStats {
1425            active_read_generations: self.active_read_generations.load(Ordering::Relaxed),
1426            max_live_read_generations: self.max_live_read_generations.load(Ordering::Relaxed),
1427            cow_clone_count: self.cow_clone_count.load(Ordering::Relaxed),
1428            cow_clone_nanos: self.cow_clone_nanos.load(Ordering::Relaxed),
1429            estimated_cow_clone_bytes: self.estimated_cow_clone_bytes.load(Ordering::Relaxed),
1430            writer_wait_nanos: self.writer_wait_nanos.load(Ordering::Relaxed),
1431        }
1432    }
1433}
1434
1435/// Immutable, structurally shared snapshot used by scored readers.
1436pub struct TableReadGeneration {
1437    table: Table,
1438    metrics: Arc<TableGenerationMetrics>,
1439}
1440
1441impl std::ops::Deref for TableReadGeneration {
1442    type Target = Table;
1443
1444    fn deref(&self) -> &Self::Target {
1445        &self.table
1446    }
1447}
1448
1449impl Drop for TableReadGeneration {
1450    fn drop(&mut self) {
1451        self.metrics
1452            .active_read_generations
1453            .fetch_sub(1, Ordering::Relaxed);
1454    }
1455}
1456
1457#[derive(Clone)]
1458enum TableHandleInner {
1459    CopyOnWrite(Arc<RwLock<Arc<Table>>>),
1460    Direct(Arc<Mutex<Table>>),
1461}
1462
1463pub enum TableGuard<'a> {
1464    CopyOnWrite {
1465        table: parking_lot::RwLockWriteGuard<'a, Arc<Table>>,
1466        metrics: Arc<TableGenerationMetrics>,
1467    },
1468    Direct {
1469        table: parking_lot::MutexGuard<'a, Table>,
1470    },
1471}
1472
1473/// Read-only guard for one mounted table.
1474///
1475/// Copy-on-write handles use the shared side of the table `RwLock`, so
1476/// independent readers do not serialize on the writer lock. Legacy direct
1477/// handles retain their existing mutex semantics.
1478pub enum TableReadGuard<'a> {
1479    CopyOnWrite {
1480        table: parking_lot::RwLockReadGuard<'a, Arc<Table>>,
1481    },
1482    Direct {
1483        table: parking_lot::MutexGuard<'a, Table>,
1484    },
1485}
1486
1487impl TableHandle {
1488    fn new(table: Table) -> Self {
1489        Self {
1490            inner: TableHandleInner::CopyOnWrite(Arc::new(RwLock::new(Arc::new(table)))),
1491            generation_metrics: Arc::new(TableGenerationMetrics::default()),
1492        }
1493    }
1494
1495    pub fn from_table(table: Table) -> Self {
1496        Self::new(table)
1497    }
1498
1499    /// Acquire a read-only table guard. Copy-on-write handles admit concurrent
1500    /// readers; callers that mutate the table must continue to use [`Self::lock`].
1501    pub fn read(&self) -> TableReadGuard<'_> {
1502        match &self.inner {
1503            TableHandleInner::CopyOnWrite(table) => TableReadGuard::CopyOnWrite {
1504                table: table.read(),
1505            },
1506            TableHandleInner::Direct(table) => TableReadGuard::Direct {
1507                table: table.lock(),
1508            },
1509        }
1510    }
1511
1512    pub fn lock(&self) -> TableGuard<'_> {
1513        let started = std::time::Instant::now();
1514        let guard = match &self.inner {
1515            TableHandleInner::CopyOnWrite(table) => TableGuard::CopyOnWrite {
1516                table: table.write(),
1517                metrics: Arc::clone(&self.generation_metrics),
1518            },
1519            TableHandleInner::Direct(table) => TableGuard::Direct {
1520                table: table.lock(),
1521            },
1522        };
1523        self.generation_metrics.writer_wait_nanos.fetch_add(
1524            started.elapsed().as_nanos().min(u128::from(u64::MAX)) as u64,
1525            Ordering::Relaxed,
1526        );
1527        guard
1528    }
1529
1530    fn try_lock_for(&self, timeout: std::time::Duration) -> Option<TableGuard<'_>> {
1531        let started = std::time::Instant::now();
1532        let guard = match &self.inner {
1533            TableHandleInner::CopyOnWrite(table) => {
1534                table
1535                    .try_write_for(timeout)
1536                    .map(|table| TableGuard::CopyOnWrite {
1537                        table,
1538                        metrics: Arc::clone(&self.generation_metrics),
1539                    })
1540            }
1541            TableHandleInner::Direct(table) => table
1542                .try_lock_for(timeout)
1543                .map(|table| TableGuard::Direct { table }),
1544        };
1545        self.generation_metrics.writer_wait_nanos.fetch_add(
1546            started.elapsed().as_nanos().min(u128::from(u64::MAX)) as u64,
1547            Ordering::Relaxed,
1548        );
1549        guard
1550    }
1551
1552    pub fn ptr_eq(&self, other: &Self) -> bool {
1553        match (&self.inner, &other.inner) {
1554            (TableHandleInner::CopyOnWrite(left), TableHandleInner::CopyOnWrite(right)) => {
1555                Arc::ptr_eq(left, right)
1556            }
1557            (TableHandleInner::Direct(left), TableHandleInner::Direct(right)) => {
1558                Arc::ptr_eq(left, right)
1559            }
1560            _ => false,
1561        }
1562    }
1563
1564    pub fn read_generation_with_context(
1565        &self,
1566        context: Option<&crate::query::AiExecutionContext>,
1567    ) -> Result<(Arc<TableReadGeneration>, Snapshot)> {
1568        let mut table = if let Some(context) = context {
1569            loop {
1570                context.checkpoint()?;
1571                let wait = context
1572                    .remaining_duration()
1573                    .unwrap_or(std::time::Duration::from_millis(5))
1574                    .min(std::time::Duration::from_millis(5));
1575                if let Some(table) = self.try_lock_for(wait) {
1576                    break table;
1577                }
1578            }
1579        } else {
1580            self.lock()
1581        };
1582        let snapshot = table.snapshot();
1583        let generation = table.clone_read_generation()?;
1584        Ok((self.generation_metrics.activate(generation), snapshot))
1585    }
1586
1587    pub fn generation_stats(&self) -> TableGenerationStats {
1588        self.generation_metrics.stats()
1589    }
1590}
1591
1592impl From<Arc<Mutex<Table>>> for TableHandle {
1593    fn from(table: Arc<Mutex<Table>>) -> Self {
1594        Self {
1595            inner: TableHandleInner::Direct(table),
1596            generation_metrics: Arc::new(TableGenerationMetrics::default()),
1597        }
1598    }
1599}
1600
1601impl std::ops::Deref for TableGuard<'_> {
1602    type Target = Table;
1603
1604    fn deref(&self) -> &Self::Target {
1605        match self {
1606            Self::CopyOnWrite { table, .. } => table.as_ref(),
1607            Self::Direct { table } => table,
1608        }
1609    }
1610}
1611
1612impl std::ops::Deref for TableReadGuard<'_> {
1613    type Target = Table;
1614
1615    fn deref(&self) -> &Self::Target {
1616        match self {
1617            Self::CopyOnWrite { table } => table.as_ref(),
1618            Self::Direct { table } => table,
1619        }
1620    }
1621}
1622
1623impl std::ops::DerefMut for TableGuard<'_> {
1624    fn deref_mut(&mut self) -> &mut Self::Target {
1625        match self {
1626            Self::CopyOnWrite { table, metrics } => {
1627                if Arc::strong_count(table) > 1 || Arc::weak_count(table) > 0 {
1628                    let estimated_bytes = table.estimated_clone_bytes();
1629                    let started = std::time::Instant::now();
1630                    let table = Arc::make_mut(table);
1631                    metrics.cow_clone_count.fetch_add(1, Ordering::Relaxed);
1632                    metrics.cow_clone_nanos.fetch_add(
1633                        started.elapsed().as_nanos().min(u128::from(u64::MAX)) as u64,
1634                        Ordering::Relaxed,
1635                    );
1636                    metrics
1637                        .estimated_cow_clone_bytes
1638                        .fetch_add(estimated_bytes, Ordering::Relaxed);
1639                    table
1640                } else {
1641                    Arc::make_mut(table)
1642                }
1643            }
1644            Self::Direct { table } => table,
1645        }
1646    }
1647}
1648
1649#[derive(Clone, Debug)]
1650pub struct ReadAuthorization {
1651    pub operation: crate::auth::ColumnOperation,
1652    pub columns: Vec<u16>,
1653    pub permissions: Vec<crate::auth::Permission>,
1654}
1655
1656#[derive(Default, Debug)]
1657struct TableWritePermissionNeeds {
1658    insert: bool,
1659    insert_columns: Vec<u16>,
1660    update: bool,
1661    update_columns: Vec<u16>,
1662    delete: bool,
1663    truncate: bool,
1664}
1665
1666#[cfg(test)]
1667thread_local! {
1668    static WRITE_PERMISSION_DECISIONS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
1669    static AUTO_INCREMENT_TABLE_LOCKS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
1670    static PREBUILD_TABLE_LOCKS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
1671    static PUBLISH_TABLE_LOCKS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
1672    static COMMIT_MANIFEST_WRITES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
1673    static TABLE_PERMISSION_DECISIONS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
1674}
1675
1676fn summarize_write_permissions(
1677    staging: &[(u64, crate::txn::Staged)],
1678) -> HashMap<u64, TableWritePermissionNeeds> {
1679    use crate::txn::Staged;
1680
1681    let mut needs = HashMap::<u64, TableWritePermissionNeeds>::new();
1682    for (table_id, operation) in staging {
1683        let table = needs.entry(*table_id).or_default();
1684        match operation {
1685            Staged::Put(cells) => {
1686                table.insert = true;
1687                table
1688                    .insert_columns
1689                    .extend(cells.iter().map(|(column, _)| *column));
1690            }
1691            Staged::Update {
1692                changed_columns, ..
1693            } => {
1694                table.update = true;
1695                table.update_columns.extend(changed_columns);
1696            }
1697            Staged::Delete(_) => table.delete = true,
1698            Staged::Truncate => table.truncate = true,
1699        }
1700    }
1701    for table in needs.values_mut() {
1702        table.insert_columns.sort_unstable();
1703        table.insert_columns.dedup();
1704        table.update_columns.sort_unstable();
1705        table.update_columns.dedup();
1706    }
1707    needs
1708}
1709
1710struct SecurityCoordinator {
1711    /// Lock order: security gate -> commit lock -> shared WAL -> table locks.
1712    gate: RwLock<()>,
1713    version: AtomicU64,
1714}
1715
1716fn security_coordinator(root: &Path, version: u64) -> Arc<SecurityCoordinator> {
1717    static COORDINATORS: std::sync::OnceLock<
1718        Mutex<HashMap<PathBuf, std::sync::Weak<SecurityCoordinator>>>,
1719    > = std::sync::OnceLock::new();
1720
1721    let root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
1722    let mut coordinators = COORDINATORS
1723        .get_or_init(|| Mutex::new(HashMap::new()))
1724        .lock();
1725    coordinators.retain(|_, coordinator| coordinator.strong_count() > 0);
1726    if let Some(coordinator) = coordinators.get(&root).and_then(std::sync::Weak::upgrade) {
1727        return coordinator;
1728    }
1729    let coordinator = Arc::new(SecurityCoordinator {
1730        gate: RwLock::new(()),
1731        version: AtomicU64::new(version),
1732    });
1733    coordinators.insert(root, Arc::downgrade(&coordinator));
1734    coordinator
1735}
1736
1737pub fn lock_table_with_context<'a>(
1738    handle: &'a TableHandle,
1739    context: Option<&crate::query::AiExecutionContext>,
1740) -> Result<TableGuard<'a>> {
1741    let Some(context) = context else {
1742        return Ok(handle.lock());
1743    };
1744    loop {
1745        context.checkpoint()?;
1746        let wait = context
1747            .remaining_duration()
1748            .unwrap_or(std::time::Duration::from_millis(5))
1749            .min(std::time::Duration::from_millis(5));
1750        if let Some(guard) = handle.try_lock_for(wait) {
1751            return Ok(guard);
1752        }
1753    }
1754}
1755
1756/// Knobs for [`Database::open_with_options`].
1757///
1758/// All fields default to the same values the convenience
1759/// [`Database::open`] / [`Database::open_encrypted`] / etc. constructors use,
1760/// so `OpenOptions::default()` round-trips the historical behavior exactly.
1761#[derive(Clone, Debug, Default)]
1762pub struct OpenOptions {
1763    /// Maximum time, in milliseconds, to wait for the cross-process database
1764    /// lock (`_meta/.lock`) before failing with `MongrelError::DatabaseLocked`.
1765    ///
1766    /// `0` (the default) preserves the historical fail-fast semantics: a
1767    /// single `try_lock_exclusive` call, no retry, no sleep. SQLite-style
1768    /// `busy_timeout` semantics kick in once this is non-zero — the open
1769    /// sleeps with progressively wider backoff (1ms → 10ms → 50ms) until
1770    /// either the lock is acquired or `lock_timeout_ms` elapses, at which
1771    /// point the open returns the same typed lock error as the fail-fast path.
1772    ///
1773    /// Only the cross-process lock is affected. Mounted tables, page-cache
1774    /// misses, and WAL appends already serialize through in-process locks
1775    /// that handle their own contention. A second independent open in the
1776    /// same process always returns `DatabaseLocked` immediately; share the
1777    /// existing `Arc<Database>` instead.
1778    pub lock_timeout_ms: u32,
1779    /// Total bytes the storage core's [`crate::memory::MemoryGovernor`] may
1780    /// hand out across every memory class (S1E-003). `None` (the default)
1781    /// uses [`DEFAULT_MEMORY_BUDGET_BYTES`]. The governor is a reservation
1782    /// accounting layer, not an allocation: this is a cap, not a preallocation.
1783    pub memory_budget_bytes: Option<u64>,
1784    /// Total bytes of live spill files the core's
1785    /// [`crate::spill::SpillManager`] allows across every query (S1E-004).
1786    /// `None` (the default) uses [`DEFAULT_TEMP_DISK_BUDGET_BYTES`]. Again a
1787    /// cap, not a preallocation.
1788    pub temp_disk_budget_bytes: Option<u64>,
1789    /// Open the database through the special offline-validation API (spec
1790    /// section 5.3): any [`crate::storage_mode::StorageMode`] — including
1791    /// `ClusterReplica`, which every normal open path rejects — is opened
1792    /// **read-only** so a backup validator can inspect it. The opened core
1793    /// rejects every write with [`MongrelError::ReadOnlyReplica`]. This is the
1794    /// only way to open a cluster replica outside the cluster node runtime.
1795    pub offline_validation: bool,
1796    /// Optional cap on durable WAL bytes recovered during open. `None` uses
1797    /// `MONGRELDB_MAX_RECOVERY_WAL_BYTES` when set, otherwise unlimited.
1798    pub max_recovery_wal_bytes: Option<u64>,
1799    /// Optional cap on WAL records recovered during open. `None` uses
1800    /// `MONGRELDB_MAX_RECOVERY_WAL_RECORDS` when set, otherwise unlimited.
1801    pub max_recovery_wal_records: Option<usize>,
1802}
1803
1804impl OpenOptions {
1805    /// Set [`OpenOptions::lock_timeout_ms`]. `0` keeps the fail-fast default;
1806    /// SQLite-style applications typically pick 1_000 – 5_000ms.
1807    pub fn with_lock_timeout_ms(mut self, ms: u32) -> Self {
1808        self.lock_timeout_ms = ms;
1809        self
1810    }
1811
1812    /// Set [`OpenOptions::memory_budget_bytes`].
1813    pub fn with_memory_budget_bytes(mut self, bytes: u64) -> Self {
1814        self.memory_budget_bytes = Some(bytes);
1815        self
1816    }
1817
1818    /// Set [`OpenOptions::temp_disk_budget_bytes`].
1819    pub fn with_temp_disk_budget_bytes(mut self, bytes: u64) -> Self {
1820        self.temp_disk_budget_bytes = Some(bytes);
1821        self
1822    }
1823
1824    /// Set [`OpenOptions::offline_validation`]. `true` opens any storage mode
1825    /// read-only (the offline backup-validator API of spec section 5.3).
1826    pub fn with_offline_validation(mut self, offline_validation: bool) -> Self {
1827        self.offline_validation = offline_validation;
1828        self
1829    }
1830
1831    /// Set [`OpenOptions::max_recovery_wal_bytes`].
1832    pub fn with_max_recovery_wal_bytes(mut self, bytes: u64) -> Self {
1833        self.max_recovery_wal_bytes = Some(bytes);
1834        self
1835    }
1836
1837    /// Set [`OpenOptions::max_recovery_wal_records`].
1838    pub fn with_max_recovery_wal_records(mut self, records: usize) -> Self {
1839        self.max_recovery_wal_records = Some(records);
1840        self
1841    }
1842}
1843
1844/// How an open/create path treats the durable storage-mode marker (spec
1845/// section 5.3, Stage 2E). Threaded from the public constructors through the
1846/// open chain into [`Database::finish_open`].
1847#[derive(Debug, Clone)]
1848pub(crate) enum OpenModeGate {
1849    /// Normal embedded/server open: `ClusterReplica` markers are rejected with
1850    /// [`crate::storage_mode::StorageModeError::ClusterReplicaRequiresClusterRuntime`].
1851    Normal,
1852    /// Process-local shared core. Authentication binds to each issued handle,
1853    /// so core recovery and table mounting proceed without a caller principal.
1854    SharedCore,
1855    /// Offline backup validator: any mode opens, forced read-only.
1856    OfflineValidation,
1857    /// Cluster node runtime: the marker must exist and equal this
1858    /// `ClusterReplica` identity; the core opens read-only for user paths (all
1859    /// writes arrive through the replicated apply path).
1860    ClusterRuntime {
1861        /// Expected owning cluster.
1862        cluster_id: mongreldb_types::ids::ClusterId,
1863        /// Expected owning node.
1864        node_id: mongreldb_types::ids::NodeId,
1865        /// Expected replicated database.
1866        database_id: mongreldb_types::ids::DatabaseId,
1867    },
1868    /// Fresh create: no marker may exist yet; this mode is written.
1869    Create(crate::storage_mode::StorageMode),
1870}
1871
1872/// Default node-level memory budget (S1E-003): 1 GiB. Comfortably covers the
1873/// two 64 MiB caches with headroom for query execution, result buffering, and
1874/// maintenance reservations.
1875pub const DEFAULT_MEMORY_BUDGET_BYTES: u64 = 1024 * 1024 * 1024;
1876
1877/// Default node-level temporary-disk (spill) budget (S1E-004): 4 GiB of live
1878/// spill files across every query on the core.
1879pub const DEFAULT_TEMP_DISK_BUDGET_BYTES: u64 = 4 * 1024 * 1024 * 1024;
1880
1881/// Resolved node-level resource budgets for one storage core
1882/// (S1E-003/S1E-004), threaded from [`OpenOptions`] through the open chain
1883/// into [`Database::finish_open`].
1884#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1885pub(crate) struct CoreResourceConfig {
1886    pub memory_budget_bytes: u64,
1887    pub temp_disk_budget_bytes: u64,
1888    pub wal_replay_limits: crate::wal::WalReplayLimits,
1889}
1890
1891impl Default for CoreResourceConfig {
1892    fn default() -> Self {
1893        Self {
1894            memory_budget_bytes: DEFAULT_MEMORY_BUDGET_BYTES,
1895            temp_disk_budget_bytes: DEFAULT_TEMP_DISK_BUDGET_BYTES,
1896            wal_replay_limits: crate::wal::WalReplayLimits::from_env().unwrap_or_default(),
1897        }
1898    }
1899}
1900
1901impl CoreResourceConfig {
1902    fn from_options(options: &OpenOptions) -> Result<Self> {
1903        let config = Self {
1904            memory_budget_bytes: options
1905                .memory_budget_bytes
1906                .unwrap_or(DEFAULT_MEMORY_BUDGET_BYTES),
1907            temp_disk_budget_bytes: options
1908                .temp_disk_budget_bytes
1909                .unwrap_or(DEFAULT_TEMP_DISK_BUDGET_BYTES),
1910            wal_replay_limits: crate::wal::WalReplayLimits::from_env()?.overlay(
1911                options.max_recovery_wal_bytes,
1912                options.max_recovery_wal_records,
1913            ),
1914        };
1915        if config.memory_budget_bytes == 0 {
1916            return Err(MongrelError::InvalidArgument(
1917                "memory_budget_bytes must be nonzero".into(),
1918            ));
1919        }
1920        if config.temp_disk_budget_bytes == 0 {
1921            return Err(MongrelError::InvalidArgument(
1922                "temp_disk_budget_bytes must be nonzero".into(),
1923            ));
1924        }
1925        Ok(config)
1926    }
1927}
1928
1929/// Per-table version-retention pin diagnostics (S1C-004): one mounted table's
1930/// active pin sources as reported by [`crate::engine::Table::version_pins_report`].
1931#[derive(Debug, Clone)]
1932pub struct TablePinsReport {
1933    /// The mounted table's id.
1934    pub table_id: u64,
1935    /// The mounted table's catalog name.
1936    pub table: String,
1937    /// Every active version-retention pin source on the table.
1938    pub pins: crate::retention::PinsReport,
1939}
1940
1941/// The shared storage core (spec §10.1, S1A-001): one catalog, one epoch
1942/// clock, shared caches, a shared WAL, and a live map of name → `Arc<Table>`.
1943///
1944/// `DatabaseCore` owns every storage resource — the durable root, the
1945/// exclusive lease, the catalog, the commit log, the epoch authority, the
1946/// transaction state, the mounted tables, and the page caches — plus the
1947/// lifecycle state machine (S1A-004). It is shared through an `Arc`: the
1948/// exclusive [`Database`] owner holds one reference, and every
1949/// [`crate::handle::DatabaseHandle`] issued by
1950/// [`crate::manager::DatabaseManager`] holds another. The core deliberately
1951/// does **not** store one mutable "current principal" (spec §4.6): per-caller
1952/// identity lives on the handle types, not inside shared storage state.
1953pub struct DatabaseCore {
1954    root: PathBuf,
1955    durable_root: Arc<crate::durable_file::DurableRoot>,
1956    /// Lifecycle state machine (spec §10.1, S1A-004). Shared through an `Arc`
1957    /// so [`crate::core::OperationGuard`]s are `'static` and thread-safe.
1958    lifecycle: Arc<crate::core::LifecycleController>,
1959    /// Set by [`crate::manager::DatabaseManager`] when this core backs shared
1960    /// handles: lets `shutdown()` transition the registry entry through
1961    /// `Closing` to removal. `None` for exclusively-owned cores.
1962    registry: std::sync::OnceLock<crate::manager::CoreRegistration>,
1963    /// Set by `_meta/replica`; user writes are rejected on follower copies.
1964    read_only: bool,
1965    catalog: RwLock<Catalog>,
1966    security_coordinator: Arc<SecurityCoordinator>,
1967    security_catalog_disk_reads: AtomicU64,
1968    rls_cache: Mutex<RlsCache>,
1969    epoch: Arc<EpochAuthority>,
1970    snapshots: Arc<SnapshotRegistry>,
1971    /// S1E-003: the node-level memory governor for this core. Exactly one per
1972    /// core; both caches below hold reservations under it and are registered
1973    /// as reclaimable subsystems it can evict under pressure (escalation
1974    /// step 2).
1975    memory_governor: crate::memory::MemoryGovernor,
1976    page_cache: Arc<crate::cache::Sharded<crate::cache::PageCache>>,
1977    decoded_cache: Arc<crate::cache::Sharded<crate::cache::DecodedPageCache>>,
1978    /// S1E-004: the node-level spill manager, rooted at `<db-root>/temp/spill`
1979    /// and sealed with the database meta DEK when encryption is enabled. Its
1980    /// startup sweep ran during open; per-query sessions draw from it.
1981    spill_manager: crate::spill::SpillManager,
1982    /// S1E-002: workload resource groups for admission (concurrency, memory,
1983    /// temp disk, work units). Seeded with class defaults at open; operators
1984    /// reconfigure through [`Database::resource_groups`].
1985    resource_groups: crate::resource::ResourceGroupRegistry,
1986    /// Optional pluggable embedding providers (local models / registered
1987    /// backends). Empty by default — dense ANN uses application-supplied
1988    /// vectors; sparse retrieval needs no provider.
1989    embedding_providers: crate::embedding::EmbeddingProviderRegistry,
1990    /// Authenticated service-principal definitions for shared-handle open
1991    /// (P0.1). Authority is stored here and re-resolved live per operation;
1992    /// callers cannot supply their own permission vectors on attach.
1993    pub(crate) service_principals: crate::service_principal::ServicePrincipalStore,
1994    /// S1F-002: the durable job registry (sibling `JOBS` file). Exactly one
1995    /// per core; jobs recovered mid-`Running` by a crash come back `Paused`.
1996    job_registry: Arc<crate::jobs::JobRegistry>,
1997    commit_lock: Arc<Mutex<()>>,
1998    kms_rotation_lock: Mutex<()>,
1999    /// One shared WAL multiplexing every table's records (spec §7.2). Owned
2000    /// behind a `Mutex` so the transaction layer can append + group-sync. Shared
2001    /// (via `Arc`) with every mounted `Table` so single-table `put`/`commit`
2002    /// writes also land in this one WAL (B1 — one WAL per database).
2003    shared_wal: Arc<Mutex<crate::wal::SharedWal>>,
2004    /// Monotonic per-open transaction-id counter. Scoped by `open_generation`
2005    /// in P2.7; here it just needs to be unique within an open. Shared with
2006    /// mounted tables so their auto-commit txn ids never alias cross-table ones.
2007    next_txn_id: Arc<Mutex<u64>>,
2008    tables: RwLock<HashMap<u64, TableHandle>>,
2009    kek: Option<Arc<crate::encryption::Kek>>,
2010    /// Serializes DDL (create/drop table); data commits serialize through
2011    /// `commit_lock` shared via `SharedCtx`.
2012    ddl_lock: Mutex<()>,
2013    meta_dek: Option<[u8; META_DEK_LEN]>,
2014    /// P3.4: when staged bytes per table exceed this, write a uniform-epoch
2015    /// pending run to `_txn/<txn_id>/` instead of streaming Put records (§8.5).
2016    spill_threshold: std::sync::atomic::AtomicU64,
2017    /// P3.1: write-key → commit_epoch for first-committer-wins conflict
2018    /// detection (spec §9.2).
2019    conflicts: crate::txn::ConflictIndex,
2020    /// P3.1: min read_epoch of all in-flight txns, drives conflict-index
2021    /// pruning (spec §9.2, review fix #12).
2022    active_txns: crate::txn::ActiveTxns,
2023    /// S1B-003: the core's key/predicate lock manager. One per core; commits
2024    /// acquire unique-constraint key claims, FK parent-protection holds,
2025    /// sequence-allocation barriers, and the DML Shared hold on the schema
2026    /// barrier (DDL takes it Exclusive), all released when the transaction
2027    /// ends.
2028    lock_manager: Arc<crate::locks::LockManager>,
2029    /// P3.2: set on fsync error — all subsequent writes fail fast (spec §9.3e).
2030    /// Shared with mounted tables so a single-table commit also honors poison.
2031    poisoned: Arc<std::sync::atomic::AtomicBool>,
2032    /// P3.2: group-commit coordinator. The sequencer appends under the WAL lock
2033    /// but defers the fsync to one leader here, so concurrent commits share a
2034    /// single fsync (spec §9.3). Shared with mounted tables.
2035    group: Arc<crate::txn::GroupCommit>,
2036    /// FND-004 (spec §9.4): configured commit authority. Standalone cores bind
2037    /// `StandaloneCommitLog`; cluster runtime replaces it with the tablet
2038    /// group's `RaftCommitLog` after group construction.
2039    commit_log: RwLock<Arc<dyn mongreldb_log::CommitLog>>,
2040    /// Standalone WAL adapter used only by local standalone mutation paths.
2041    /// Cluster replicas reject those paths and receive mutations through
2042    /// committed apply.
2043    standalone_commit_log: Arc<crate::commit_log::StandaloneCommitLog>,
2044    /// The node's single HLC timestamp authority (spec §4.1, §8.2; ADR-0003).
2045    /// Transaction read timestamps are captured at `begin`, and commit
2046    /// timestamps are allocated here under the sequencer lock (strictly after
2047    /// every participant read/write timestamp). `Epoch` remains the
2048    /// reader-visibility counter during the dual-model migration.
2049    hlc: Arc<mongreldb_types::hlc::HlcClock>,
2050    /// S1B-005: durable idempotency ledger for keyed remote writes, persisted
2051    /// in the sibling `TXN_IDEMPOTENCY` file (mirroring `jobs.rs`'s `JOBS`).
2052    idempotency: crate::txn::IdempotencyLedger,
2053    /// Per-open epoch → commit-timestamp ledger backing
2054    /// [`Database::commit_ts_for_epoch`]. Commits sealed within this open
2055    /// record the exact receipt `HlcTimestamp`; commits recovered from the
2056    /// durable `Op::CommitTimestamp` WAL ledger at open reconstruct the
2057    /// physical component only (`logical`/`node_tiebreaker` are 0 — the
2058    /// ledger byte format stores micros). Bounded to the newest
2059    /// [`COMMIT_TS_LEDGER_CAP`] epochs.
2060    commit_ts_ledger: Mutex<std::collections::BTreeMap<u64, mongreldb_types::hlc::HlcTimestamp>>,
2061    /// P3.6: txn ids currently spilling into `_txn/<id>/`. GC never deletes a
2062    /// live spill's pending dir (review fix #14, spec §6.4).
2063    active_spills: Arc<crate::retention::ActiveSpills>,
2064    /// A write lock captures a consistent bootstrap image; transaction commits
2065    /// hold a read lock across spill preparation, WAL append, and publish.
2066    replication_barrier: parking_lot::RwLock<()>,
2067    /// Number of rotated WAL segments retained for lagging followers.
2068    replication_wal_retention_segments: AtomicUsize,
2069    /// Live immutable run files used by online backups or scored read
2070    /// generations. GC cannot unlink them until every owning guard drops.
2071    backup_pins: Arc<Mutex<HashMap<(u64, u128), usize>>>,
2072    /// Test-only barrier invoked after a transaction writes its spill runs but
2073    /// before the sequencer/publish, so tests can race `gc()` against an
2074    /// in-flight spill. `None` in production.
2075    #[doc(hidden)]
2076    spill_hook: Mutex<Option<Box<dyn Fn() + Send + Sync>>>,
2077    /// Test seam after the security read gate is held and before WAL append.
2078    #[doc(hidden)]
2079    security_commit_hook: Mutex<Option<Box<dyn Fn() + Send + Sync>>>,
2080    /// Test seam after transaction preparation and before catalog generation
2081    /// validation under the commit sequencer.
2082    #[doc(hidden)]
2083    catalog_commit_hook: Mutex<Option<Box<dyn Fn() + Send + Sync>>>,
2084    /// Test seam after a backup boundary is captured and before pinned runs are
2085    /// copied. Lets tests compact+GC the source at the worst possible moment.
2086    #[doc(hidden)]
2087    backup_hook: Mutex<Option<Box<dyn Fn() + Send + Sync>>>,
2088    /// Test seam invoked after each successful FK parent-protection lock
2089    /// acquisition inside constraint validation, so tests can rendezvous two
2090    /// committing transactions into a deterministic wait-for cycle. Behind an
2091    /// `Arc` so firing never holds the slot's mutex — concurrent commits (and
2092    /// a hook that itself parks) must not serialize on it.
2093    #[doc(hidden)]
2094    fk_lock_hook: Mutex<Option<Arc<dyn Fn() + Send + Sync>>>,
2095    replication_hook: Mutex<Option<Box<dyn Fn() + Send + Sync>>>,
2096    trigger_recursive: AtomicBool,
2097    trigger_max_depth: AtomicU32,
2098    trigger_max_loop_iterations: AtomicU32,
2099    /// Lightweight channel for ephemeral SQL NOTIFY messages. Durable row CDC
2100    /// is reconstructed from the WAL by [`Database::change_events_since`].
2101    notify: tokio::sync::broadcast::Sender<ChangeEvent>,
2102    /// Commit-time wake-up for durable CDC consumers. Payloads are reconstructed
2103    /// from the WAL, so lagged receivers lose only a wake-up, never data.
2104    change_wake: tokio::sync::broadcast::Sender<()>,
2105    /// Final field so every storage resource drops before the exclusive lease.
2106    /// Behind a `Mutex<Option<_>>` so `shutdown()` can release the file lock
2107    /// (spec §10.1, S1A-004 step 7) while handles still reference the core.
2108    _lock: Mutex<Option<ExclusiveDatabaseLease>>,
2109}
2110
2111/// The exclusive public owner of one [`DatabaseCore`] (spec §10.1, S1A-001).
2112///
2113/// `Database::open`/`create*` build exactly one core for a root and reject any
2114/// other core — exclusive or shared — for the same root (spec §2.6). All
2115/// storage state lives on the core; this type adds the owner-bound identity
2116/// context (the cached principal and the shared auth state used by the
2117/// embedded enforcement path). Method calls and field reads transparently
2118/// reach the core through `Deref`, so existing `Database` call sites are
2119/// unchanged.
2120pub struct Database {
2121    core: Arc<DatabaseCore>,
2122    /// The authenticated principal for this owner. `None` on databases
2123    /// opened without credentials (the default — `require_auth = false`),
2124    /// `Some` on credentialed opens. Consulted by every enforcement point
2125    /// when the catalog's `require_auth` flag is set. Behind an `RwLock`
2126    /// because the access pattern is read-heavy: every `require()` call
2127    /// reads the principal, while writes happen only at open, `enable_auth`,
2128    /// and `refresh_principal`. This matches the engine's existing use of
2129    /// `RwLock` for `catalog` and `tables`.
2130    /// See `docs/15-credential-enforcement.md`.
2131    principal: RwLock<Option<crate::auth::Principal>>,
2132    /// Shared, cloneable handle to the auth state (require_auth flag from the
2133    /// catalog + the principal). Cloned into every mounted `Table` so the
2134    /// Table layer can enforce permissions without holding a reference back
2135    /// to `Database` (which would create a cycle). `AuthState` is already
2136    /// cheaply cloneable (inner `Arc`), so no outer `Arc` is needed.
2137    auth_state: crate::auth_state::AuthState,
2138    /// `true` when this value is a manager-issued facade over a shared core
2139    /// (Stage 1A): dropping it has no storage side effects, `shutdown()`
2140    /// rejects, and auth-mode transitions are refused so one handle cannot
2141    /// flip the shared core into an enforcement mode other handles cannot
2142    /// observe (fail closed; per-handle enforcement lands with Stage 1D
2143    /// sessions).
2144    shared: bool,
2145}
2146
2147impl std::ops::Deref for Database {
2148    type Target = DatabaseCore;
2149
2150    fn deref(&self) -> &DatabaseCore {
2151        &self.core
2152    }
2153}
2154
2155struct RunPins {
2156    pins: Arc<Mutex<HashMap<(u64, u128), usize>>>,
2157    runs: Vec<(u64, u128)>,
2158}
2159
2160/// RAII proof that one transaction's lock holds release when the scope that
2161/// acquired them exits (S1B-004 step 12). Used by the commit path (covers
2162/// success, validation failure, and poison alike) and by DDL entry points
2163/// holding the Exclusive schema barrier.
2164struct TxnLockGuard<'a> {
2165    locks: &'a crate::locks::LockManager,
2166    txn_id: u64,
2167}
2168
2169impl Drop for TxnLockGuard<'_> {
2170    fn drop(&mut self) {
2171        self.locks.release_all(self.txn_id);
2172    }
2173}
2174
2175struct BackupFilePins {
2176    root: PathBuf,
2177}
2178
2179struct PendingTableDir {
2180    path: PathBuf,
2181    armed: bool,
2182}
2183
2184impl PendingTableDir {
2185    fn new(path: PathBuf) -> Self {
2186        Self { path, armed: true }
2187    }
2188
2189    fn disarm(&mut self) {
2190        self.armed = false;
2191    }
2192}
2193
2194impl Drop for PendingTableDir {
2195    fn drop(&mut self) {
2196        if self.armed {
2197            let _ = std::fs::remove_dir_all(&self.path);
2198        }
2199    }
2200}
2201
2202impl Drop for BackupFilePins {
2203    fn drop(&mut self) {
2204        let _ = std::fs::remove_dir_all(&self.root);
2205    }
2206}
2207
2208impl Drop for RunPins {
2209    fn drop(&mut self) {
2210        let mut pins = self.pins.lock();
2211        for run in &self.runs {
2212            if let Some(count) = pins.get_mut(run) {
2213                *count -= 1;
2214                if *count == 0 {
2215                    pins.remove(run);
2216                }
2217            }
2218        }
2219    }
2220}
2221
2222/// A durable data-change event reconstructed from committed WAL records, or an
2223/// ephemeral SQL `NOTIFY` event when `id` is `None`.
2224#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
2225pub struct ChangeEvent {
2226    pub id: Option<String>,
2227    pub channel: String,
2228    pub table_id: Option<u64>,
2229    pub table: String,
2230    pub op: String,
2231    pub epoch: u64,
2232    pub txn_id: Option<u64>,
2233    pub message: Option<String>,
2234    pub data: Option<serde_json::Value>,
2235}
2236
2237#[derive(Debug, Clone)]
2238pub struct CdcBatch {
2239    pub events: Vec<ChangeEvent>,
2240    pub current_epoch: u64,
2241    pub earliest_epoch: Option<u64>,
2242    pub gap: bool,
2243}
2244
2245/// Manual `Debug` for `Database` — surfaces the diagnostics-relevant fields
2246/// (root, epoch, table count, encryption/auth state) without requiring every
2247/// internal type (Table, GroupCommit, broadcast sender, etc.) to impl Debug.
2248/// The raw field types carry locks, trait objects, and channels that have no
2249/// useful `Debug` output, so a hand-written impl is clearer than peppering
2250/// `#[allow(dead_code)]` skip attributes across two dozen fields.
2251impl std::fmt::Debug for Database {
2252    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2253        let cat = self.catalog.read();
2254        let principal_guard = self.principal.read();
2255        let principal: &str = principal_guard
2256            .as_ref()
2257            .map(|p| p.username.as_str())
2258            .unwrap_or("<none>");
2259        f.debug_struct("Database")
2260            .field("root", &self.root)
2261            .field("db_epoch", &cat.db_epoch)
2262            .field("open_generation", &"sidecar")
2263            .field("tables", &cat.tables.len())
2264            .field("visible_epoch", &self.epoch.visible().0)
2265            .field("encrypted", &self.kek.is_some())
2266            .field("require_auth", &cat.require_auth)
2267            .field("principal", &principal)
2268            .finish()
2269    }
2270}
2271
2272/// Manual `Debug` for `DatabaseCore`, mirroring `Database`'s (see above). The
2273/// core has no cached principal by design (spec §4.6).
2274impl std::fmt::Debug for DatabaseCore {
2275    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2276        let cat = self.catalog.read();
2277        f.debug_struct("DatabaseCore")
2278            .field("root", &self.root)
2279            .field("lifecycle", &self.lifecycle.state())
2280            .field("db_epoch", &cat.db_epoch)
2281            .field("tables", &cat.tables.len())
2282            .field("visible_epoch", &self.epoch.visible().0)
2283            .field("encrypted", &self.kek.is_some())
2284            .field("require_auth", &cat.require_auth)
2285            .finish()
2286    }
2287}
2288
2289impl DatabaseCore {
2290    fn ensure_owner_process(&self) -> Result<()> {
2291        let current_pid = std::process::id();
2292        let owner_pid = self
2293            ._lock
2294            .lock()
2295            .as_ref()
2296            .map(|lease| lease.owner_pid)
2297            .unwrap_or(current_pid);
2298        if current_pid == owner_pid {
2299            Ok(())
2300        } else {
2301            Err(MongrelError::ForkedProcess {
2302                owner_pid,
2303                current_pid,
2304            })
2305        }
2306    }
2307
2308    /// The canonical filesystem root this core was opened at.
2309    pub fn root(&self) -> &Path {
2310        self.durable_root.canonical_path()
2311    }
2312
2313    /// The stable directory-handle identity of this core's root (S1A-003).
2314    pub fn file_identity(&self) -> Result<crate::core::DatabaseFileIdentity> {
2315        crate::core::DatabaseFileIdentity::from_durable_root(&self.durable_root)
2316    }
2317
2318    /// The current lifecycle state (S1A-004).
2319    pub fn lifecycle_state(&self) -> crate::core::LifecycleState {
2320        self.lifecycle.state()
2321    }
2322
2323    /// Whether this core currently admits new operations.
2324    pub fn is_open(&self) -> bool {
2325        self.lifecycle.is_open()
2326    }
2327
2328    /// Admit one operation against this core (S1A-004: "every operation holds
2329    /// an `OperationGuard`"). Rejected unless the core is
2330    /// [`crate::core::LifecycleState::Open`].
2331    pub fn operation_guard(self: &Arc<Self>) -> Result<crate::core::OperationGuard> {
2332        self.lifecycle.begin_operation()
2333    }
2334
2335    /// Register this core with the [`crate::manager::DatabaseManager`] that
2336    /// initialized it (S1A-002). Called once, before the first handle is
2337    /// handed out.
2338    pub(crate) fn set_registry(
2339        &self,
2340        registration: crate::manager::CoreRegistration,
2341    ) -> Result<()> {
2342        self.registry
2343            .set(registration)
2344            .map_err(|_| MongrelError::Conflict("database core is already registry-bound".into()))
2345    }
2346
2347    /// Ordered core shutdown (spec §10.1, S1A-004):
2348    ///
2349    /// 1. Transition `Open` → `Draining`.
2350    /// 2. Reject new sessions and writes (new [`Self::operation_guard`]s fail
2351    ///    from this point).
2352    /// 3. Cancel non-commit-critical queries — a no-op in Stage 1A: the
2353    ///    embedded core has no central query registry yet (Stage 1D adds one),
2354    ///    and every in-flight operation is treated as commit-critical and
2355    ///    drained rather than cancelled.
2356    /// 4. Wait for in-flight operations, up to `drain_deadline`. On timeout
2357    ///    the core stays `Draining` and a later call may resume the shutdown.
2358    /// 5. Sync required durable state (group-sync the shared WAL).
2359    /// 6. Stop workers — the Stage 1A core runs no background worker set.
2360    /// 7. Release the file lock (and the process open-registry entries).
2361    /// 8. Mark `Closed`.
2362    ///
2363    /// Repeated calls after `Closed` return `Ok(())`. Dropping one handle has
2364    /// no storage side effects; only this method (or the last `Arc` drop)
2365    /// closes storage.
2366    pub fn shutdown(self: &Arc<Self>, drain_deadline: std::time::Duration) -> Result<()> {
2367        self.ensure_owner_process()?;
2368        let initiated = self.lifecycle.begin_shutdown();
2369        if !initiated {
2370            match self.lifecycle.state() {
2371                crate::core::LifecycleState::Closed => return Ok(()),
2372                // A concurrent or timed-out shutdown left the core draining;
2373                // join it and drive the remaining steps.
2374                crate::core::LifecycleState::Draining => {}
2375                state => {
2376                    return Err(MongrelError::Conflict(format!(
2377                        "database core cannot shut down from lifecycle state {state}"
2378                    )))
2379                }
2380            }
2381        }
2382        if let Some(registration) = self.registry.get() {
2383            registration
2384                .manager
2385                .mark_closing(&registration.identity, self);
2386        }
2387        self.lifecycle.wait_drained(drain_deadline)?;
2388        let sync = self.shared_wal.lock().group_sync().map(|_| ());
2389        self.lifecycle.mark_closing();
2390        let lease = self._lock.lock().take();
2391        drop(lease);
2392        if let Some(registration) = self.registry.get() {
2393            registration
2394                .manager
2395                .entry_closed(&registration.identity, self);
2396        }
2397        self.lifecycle.mark_closed();
2398        sync
2399    }
2400}
2401
2402impl Database {
2403    pub fn open_metrics() -> DatabaseOpenMetrics {
2404        DatabaseOpenMetrics {
2405            lock_waits: DATABASE_OPEN_WAIT_COUNT.load(Ordering::Relaxed),
2406            failures: DATABASE_OPEN_FAILURE_COUNT.load(Ordering::Relaxed),
2407        }
2408    }
2409
2410    /// The storage core this owner references (spec §10.1, S1A-001).
2411    pub fn core(&self) -> Arc<DatabaseCore> {
2412        Arc::clone(&self.core)
2413    }
2414
2415    /// The identity bound to this owner (spec §10.1, S1A-001): a catalog user
2416    /// for credentialed opens, `Credentialless` otherwise.
2417    pub fn identity(&self) -> crate::handle::HandleIdentity {
2418        match self.principal.read().as_ref() {
2419            Some(principal) => crate::handle::HandleIdentity::CatalogUser {
2420                username: principal.username.clone(),
2421                user_id: principal.user_id,
2422                created_version: principal.created_epoch,
2423            },
2424            None => crate::handle::HandleIdentity::Credentialless,
2425        }
2426    }
2427
2428    /// The current lifecycle state of the underlying storage core (S1A-004).
2429    pub fn lifecycle_state(&self) -> crate::core::LifecycleState {
2430        self.core.lifecycle_state()
2431    }
2432
2433    /// Admit one operation against the storage core (S1A-004). The returned
2434    /// RAII guard releases the operation slot on drop; new operations are
2435    /// rejected once the core leaves [`crate::core::LifecycleState::Open`].
2436    pub fn operation_guard(&self) -> Result<crate::core::OperationGuard> {
2437        self.core.operation_guard()
2438    }
2439
2440    /// Build an owner facade over an existing shared core. Used by
2441    /// [`crate::manager::DatabaseManager`] to back [`crate::handle::DatabaseHandle`]s;
2442    /// exclusive `Database::open*` constructors build their own core instead.
2443    pub(crate) fn from_core(
2444        core: Arc<DatabaseCore>,
2445        principal: Option<crate::auth::Principal>,
2446        shared: bool,
2447    ) -> Self {
2448        let require_auth = core.catalog.read().require_auth;
2449        Self {
2450            core,
2451            principal: RwLock::new(principal.clone()),
2452            auth_state: crate::auth_state::AuthState::new(require_auth, principal),
2453            shared,
2454        }
2455    }
2456
2457    /// Rebind this facade's principal to the live service-principal definition
2458    /// (P0.1). Called by [`crate::handle::DatabaseHandle`] after each
2459    /// `resolve_live` so subsequent require/txn checks see current scopes.
2460    pub(crate) fn rebind_service_principal(
2461        &self,
2462        def: &crate::service_principal::ServicePrincipalDefinition,
2463    ) {
2464        let principal = crate::auth::Principal {
2465            user_id: 0,
2466            created_epoch: def.creation_version,
2467            username: format!("service:{}", def.token_id),
2468            is_admin: false,
2469            roles: Vec::new(),
2470            permissions: def.permissions.clone(),
2471        };
2472        *self.principal.write() = Some(principal.clone());
2473        self.auth_state.set_principal(Some(principal));
2474    }
2475
2476    /// Consume this owner and yield the shared core (keeps the core alive).
2477    pub(crate) fn into_core(self) -> Arc<DatabaseCore> {
2478        self.core
2479    }
2480
2481    /// Open an existing plaintext database as the one core backing shared
2482    /// handles (spec §10.1, S1A-002). Recovery, WAL opening, open-generation
2483    /// advancement, and table mounting all happen inside this call — exactly
2484    /// once per core.
2485    pub(crate) fn open_for_shared_core(root: impl AsRef<Path>) -> Result<Self> {
2486        Self::open_inner_with_lock_timeout(
2487            root,
2488            None,
2489            None,
2490            0,
2491            CoreResourceConfig::default(),
2492            OpenModeGate::SharedCore,
2493        )
2494    }
2495
2496    /// Explicitly close the final shared database owner.
2497    ///
2498    /// On a manager-issued facade (`shared`) this rejects: dropping one handle
2499    /// never closes storage while another handle exists, and shared cores are
2500    /// shut down explicitly via [`crate::handle::DatabaseHandle::shutdown`].
2501    pub fn shutdown(self: Arc<Self>) -> Result<()> {
2502        if self.shared {
2503            return Err(MongrelError::Conflict(
2504                "shared-core facades do not own storage; use DatabaseHandle::shutdown".into(),
2505            ));
2506        }
2507        match Arc::try_unwrap(self) {
2508            Ok(database) => {
2509                database.ensure_owner_process()?;
2510                // Drain + close the exclusively-owned core (S1A-004 steps:
2511                // reject new operations, wait for the drain, sync durable
2512                // state, release the file lock, mark Closed), then drop it.
2513                database.core.shutdown(std::time::Duration::from_secs(30))?;
2514                drop(database);
2515                Ok(())
2516            }
2517            Err(database) => Err(MongrelError::DatabaseBusy {
2518                strong_handles: Arc::strong_count(&database),
2519            }),
2520        }
2521    }
2522
2523    fn canonical_lock_target(root: &Path) -> std::io::Result<(PathBuf, PathBuf)> {
2524        if let Ok(canonical) = root.canonicalize() {
2525            let lock_dir = canonical.parent().ok_or_else(|| {
2526                std::io::Error::new(
2527                    std::io::ErrorKind::InvalidInput,
2528                    "database root must have a parent directory",
2529                )
2530            })?;
2531            return Ok((canonical.clone(), lock_dir.to_path_buf()));
2532        }
2533
2534        let absolute = if root.is_absolute() {
2535            root.to_path_buf()
2536        } else {
2537            std::env::current_dir()?.join(root)
2538        };
2539        let mut cursor = absolute.as_path();
2540        let mut suffix = Vec::new();
2541        while !cursor.exists() {
2542            let name = cursor.file_name().ok_or_else(|| {
2543                std::io::Error::new(
2544                    std::io::ErrorKind::NotFound,
2545                    format!("no existing ancestor for database root {}", root.display()),
2546                )
2547            })?;
2548            suffix.push(name.to_os_string());
2549            cursor = cursor.parent().ok_or_else(|| {
2550                std::io::Error::new(
2551                    std::io::ErrorKind::NotFound,
2552                    format!("no existing ancestor for database root {}", root.display()),
2553                )
2554            })?;
2555        }
2556        let lock_dir = cursor.canonicalize()?;
2557        let mut canonical = lock_dir.clone();
2558        for component in suffix.iter().rev() {
2559            canonical.push(component);
2560        }
2561        Ok((canonical, lock_dir))
2562    }
2563
2564    fn acquire_database_lock(root: &Path, timeout_ms: u32) -> Result<ExclusiveDatabaseLease> {
2565        use std::hash::{Hash, Hasher};
2566
2567        let (canonical_path, lock_dir) = Self::canonical_lock_target(root)?;
2568        let reservation =
2569            OpenReservation::acquire(DatabaseOpenKey::IntendedPath(canonical_path.clone()), root)?;
2570
2571        let mut hasher = std::collections::hash_map::DefaultHasher::new();
2572        canonical_path.hash(&mut hasher);
2573        let lock_path = lock_dir.join(format!(".mongreldb-{:016x}.lock", hasher.finish()));
2574        let file = std::fs::OpenOptions::new()
2575            .create(true)
2576            .truncate(false)
2577            .write(true)
2578            .open(lock_path)?;
2579        if let Err(error) = Self::fs_lock_exclusive(&file, timeout_ms) {
2580            return Err(MongrelError::DatabaseLocked {
2581                path: root.to_path_buf(),
2582                message: error.to_string(),
2583            });
2584        }
2585        Ok(reservation.into_lease(file, canonical_path))
2586    }
2587
2588    fn acquire_legacy_database_lock(
2589        lock: &mut ExclusiveDatabaseLease,
2590        root: &Path,
2591        timeout_ms: u32,
2592    ) -> Result<()> {
2593        let durable_root = lock
2594            .durable_root
2595            .as_ref()
2596            .ok_or_else(|| MongrelError::Other("database root descriptor was not pinned".into()))?;
2597        let file = durable_root.open_lock_file(Path::new(META_DIR).join(".lock"))?;
2598        if let Err(error) = Self::fs_lock_exclusive(&file, timeout_ms) {
2599            return Err(MongrelError::DatabaseLocked {
2600                path: root.to_path_buf(),
2601                message: error.to_string(),
2602            });
2603        }
2604        lock.legacy_file = Some(file);
2605        Ok(())
2606    }
2607
2608    fn pin_or_create_database_root(path: &Path) -> Result<crate::durable_file::DurableRoot> {
2609        if path.exists() {
2610            return crate::durable_file::DurableRoot::open(path).map_err(Into::into);
2611        }
2612        let mut ancestor = path;
2613        while !ancestor.exists() {
2614            ancestor = ancestor.parent().ok_or_else(|| {
2615                MongrelError::NotFound(format!(
2616                    "no existing ancestor for database root {}",
2617                    path.display()
2618                ))
2619            })?;
2620        }
2621        let relative = path.strip_prefix(ancestor).map_err(|error| {
2622            MongrelError::InvalidArgument(format!("invalid database root: {error}"))
2623        })?;
2624        crate::durable_file::DurableRoot::open(ancestor)?
2625            .create_directory_all_pinned(relative)
2626            .map_err(Into::into)
2627    }
2628
2629    fn begin_create(root: impl AsRef<Path>) -> Result<(PathBuf, ExclusiveDatabaseLease)> {
2630        let requested_root = root.as_ref();
2631        let mut lock = Self::acquire_database_lock(requested_root, 0)?;
2632        let root = lock.canonical_path.clone();
2633        Self::reject_existing_database(&root)?;
2634        let durable_root = Arc::new(Self::pin_or_create_database_root(&root)?);
2635        if durable_root.canonical_path() != lock.canonical_path {
2636            return Err(MongrelError::Conflict(
2637                "database root changed while it was being created".into(),
2638            ));
2639        }
2640        lock.claim_root_identity(&durable_root)?;
2641        durable_root.create_directory_all(META_DIR)?;
2642        lock.durable_root = Some(durable_root);
2643        let io_root = lock
2644            .durable_root
2645            .as_ref()
2646            .ok_or_else(|| MongrelError::Other("database root descriptor was not pinned".into()))?
2647            .io_path()?;
2648        Self::acquire_legacy_database_lock(&mut lock, &io_root, 0)?;
2649        Self::reject_existing_database(&io_root)?;
2650        Ok((io_root, lock))
2651    }
2652
2653    fn begin_open(
2654        root: impl AsRef<Path>,
2655        lock_timeout_ms: u32,
2656    ) -> Result<(PathBuf, ExclusiveDatabaseLease)> {
2657        let root = root.as_ref();
2658        let canonical_root = root.canonicalize().map_err(|error| {
2659            if error.kind() == std::io::ErrorKind::NotFound {
2660                MongrelError::NotFound(format!("database root {}: {error}", root.display()))
2661            } else {
2662                error.into()
2663            }
2664        })?;
2665        let durable_root = crate::durable_file::DurableRoot::open(&canonical_root)?;
2666        Self::begin_open_durable(durable_root, lock_timeout_ms)
2667    }
2668
2669    fn begin_open_durable(
2670        durable_root: crate::durable_file::DurableRoot,
2671        lock_timeout_ms: u32,
2672    ) -> Result<(PathBuf, ExclusiveDatabaseLease)> {
2673        let io_root = durable_root.io_path()?;
2674        let current_root = io_root.canonicalize()?;
2675        let mut lock = Self::acquire_database_lock(&current_root, lock_timeout_ms)?;
2676        lock.claim_root_identity(&durable_root)?;
2677        lock.durable_root = Some(Arc::new(durable_root));
2678        let io_root = lock
2679            .durable_root
2680            .as_ref()
2681            .ok_or_else(|| MongrelError::Other("database root descriptor was not pinned".into()))?
2682            .io_path()?;
2683        if lock
2684            .durable_root
2685            .as_ref()
2686            .ok_or_else(|| MongrelError::Other("database root descriptor was not pinned".into()))?
2687            .open_directory(META_DIR)
2688            .is_err()
2689        {
2690            return Err(MongrelError::NotFound(format!(
2691                "no database metadata found at {:?}",
2692                current_root
2693            )));
2694        }
2695        Self::acquire_legacy_database_lock(&mut lock, &io_root, lock_timeout_ms)?;
2696        Ok((io_root, lock))
2697    }
2698
2699    /// Create a fresh plaintext database at `root`.
2700    pub fn create(root: impl AsRef<Path>) -> Result<Self> {
2701        let (root, lock) = Self::begin_create(root)?;
2702        Self::create_inner(
2703            root,
2704            None,
2705            lock,
2706            crate::storage_mode::StorageMode::Standalone,
2707        )
2708    }
2709
2710    /// Create a fresh encrypted database, deriving the DB-wide KEK from a
2711    /// passphrase (Argon2id + HKDF). The salt is persisted at `_meta/keys`.
2712    pub fn create_encrypted(root: impl AsRef<Path>, passphrase: &str) -> Result<Self> {
2713        let (root, lock) = Self::begin_create(root)?;
2714        let salt = crate::encryption::random_salt()?;
2715        crate::durable_file::write_atomic(&root.join(META_DIR).join(KEYS_FILENAME), &salt)?;
2716        let kek = Arc::new(crate::encryption::Kek::derive(passphrase, &salt)?);
2717        Self::create_inner(
2718            root,
2719            Some(kek),
2720            lock,
2721            crate::storage_mode::StorageMode::Standalone,
2722        )
2723    }
2724
2725    /// Create a fresh encrypted database, deriving the DB-wide KEK from a raw
2726    /// high-entropy key via HKDF. The salt is persisted at `_meta/keys`.
2727    pub fn create_with_key(root: impl AsRef<Path>, key: &[u8]) -> Result<Self> {
2728        let (root, lock) = Self::begin_create(root)?;
2729        let salt = crate::encryption::random_salt()?;
2730        crate::durable_file::write_atomic(&root.join(META_DIR).join(KEYS_FILENAME), &salt)?;
2731        let kek = Arc::new(crate::encryption::Kek::from_raw_key(key, &salt)?);
2732        Self::create_inner(
2733            root,
2734            Some(kek),
2735            lock,
2736            crate::storage_mode::StorageMode::Standalone,
2737        )
2738    }
2739
2740    /// Create a fresh encrypted database whose random root key is wrapped by
2741    /// an external KMS provider.
2742    pub fn create_with_kms(
2743        root: impl AsRef<Path>,
2744        provider: &dyn crate::security_hardening::KeyManagementProvider,
2745        kms_key_id: &str,
2746    ) -> Result<Self> {
2747        let (root, lock) = Self::begin_create(root)?;
2748        let kek = create_kms_kek(&root, provider, kms_key_id)?;
2749        Self::create_inner(
2750            root,
2751            Some(kek),
2752            lock,
2753            crate::storage_mode::StorageMode::Standalone,
2754        )
2755    }
2756
2757    /// Create a fresh plaintext database owned by the cluster node runtime
2758    /// (spec section 5.3): the durable marker records
2759    /// [`crate::storage_mode::StorageMode::ClusterReplica`], so normal
2760    /// `Database::open*` paths reject the directory and only
2761    /// [`Database::open_cluster_replica`] (or the read-only offline validator)
2762    /// can open it afterwards. The returned core rejects user writes: every
2763    /// mutation arrives through the replicated apply path (Stage 2E).
2764    pub fn create_cluster_replica(
2765        root: impl AsRef<Path>,
2766        cluster_id: mongreldb_types::ids::ClusterId,
2767        node_id: mongreldb_types::ids::NodeId,
2768        database_id: mongreldb_types::ids::DatabaseId,
2769    ) -> Result<Self> {
2770        let (root, lock) = Self::begin_create(root)?;
2771        Self::create_inner(
2772            root,
2773            None,
2774            lock,
2775            crate::storage_mode::StorageMode::ClusterReplica {
2776                cluster_id,
2777                node_id,
2778                database_id,
2779            },
2780        )
2781    }
2782
2783    /// Open a cluster-replica database as the cluster node runtime (spec
2784    /// section 5.3). Fails closed unless the durable marker exists and exactly
2785    /// matches `expected` (a `ClusterReplica` identity): opening a replica of
2786    /// the wrong cluster or database would corrupt both. The opened core
2787    /// rejects user writes with [`MongrelError::ReadOnlyReplica`]; mutations
2788    /// arrive through the replicated apply path (Stage 2E).
2789    pub fn open_cluster_replica(
2790        root: impl AsRef<Path>,
2791        expected: &crate::storage_mode::StorageMode,
2792    ) -> Result<Self> {
2793        let Some((cluster_id, node_id, database_id)) = expected.cluster_identity() else {
2794            return Err(MongrelError::InvalidArgument(format!(
2795                "open_cluster_replica requires a ClusterReplica identity, got {expected:?}"
2796            )));
2797        };
2798        let (root, lock) = Self::begin_open(root, 0)?;
2799        Self::open_inner_locked(
2800            root,
2801            None,
2802            lock,
2803            CoreResourceConfig::default(),
2804            OpenModeGate::ClusterRuntime {
2805                cluster_id,
2806                node_id,
2807                database_id,
2808            },
2809        )
2810    }
2811
2812    fn create_inner(
2813        root: PathBuf,
2814        kek: Option<Arc<crate::encryption::Kek>>,
2815        lock: ExclusiveDatabaseLease,
2816        storage_mode: crate::storage_mode::StorageMode,
2817    ) -> Result<Self> {
2818        crate::durable_file::create_directory_all(&root.join(TABLES_DIR))?;
2819        let meta_dek = crate::encryption::meta_dek_for(kek.as_deref());
2820        let cat = Catalog::empty();
2821        catalog::write_atomic(&root, &cat, meta_dek.as_ref())?;
2822        Self::finish_open(
2823            root,
2824            cat,
2825            kek,
2826            meta_dek,
2827            false,
2828            None,
2829            None,
2830            None,
2831            lock,
2832            CoreResourceConfig::default(),
2833            OpenModeGate::Create(storage_mode),
2834        )
2835    }
2836
2837    /// Open an existing plaintext database.
2838    pub fn open(root: impl AsRef<Path>) -> Result<Self> {
2839        Self::open_inner(root, None, None)
2840    }
2841
2842    /// Open an existing encrypted database with a passphrase.
2843    pub fn open_encrypted(root: impl AsRef<Path>, passphrase: &str) -> Result<Self> {
2844        let (root, lock) = Self::begin_open(root, 0)?;
2845        let salt = read_encryption_salt(lock.durable_root.as_deref().ok_or_else(|| {
2846            MongrelError::Other("database root descriptor was not pinned".into())
2847        })?)?;
2848        let kek = Arc::new(crate::encryption::Kek::derive(passphrase, &salt)?);
2849        Self::open_inner_locked(
2850            root,
2851            Some(kek),
2852            lock,
2853            CoreResourceConfig::default(),
2854            OpenModeGate::Normal,
2855        )
2856    }
2857
2858    /// Open an existing encrypted database with a configurable cross-process
2859    /// lock timeout. Mirrors [`open_with_options`](Self::open_with_options).
2860    pub fn open_encrypted_with_options(
2861        root: impl AsRef<Path>,
2862        passphrase: &str,
2863        options: OpenOptions,
2864    ) -> Result<Self> {
2865        let (root, lock) = Self::begin_open(root, options.lock_timeout_ms)?;
2866        let salt = read_encryption_salt(lock.durable_root.as_deref().ok_or_else(|| {
2867            MongrelError::Other("database root descriptor was not pinned".into())
2868        })?)?;
2869        let kek = Arc::new(crate::encryption::Kek::derive(passphrase, &salt)?);
2870        let gate = if options.offline_validation {
2871            OpenModeGate::OfflineValidation
2872        } else {
2873            OpenModeGate::Normal
2874        };
2875        Self::open_inner_locked(
2876            root,
2877            Some(kek),
2878            lock,
2879            CoreResourceConfig::from_options(&options)?,
2880            gate,
2881        )
2882    }
2883
2884    /// Open an existing encrypted database using a raw high-entropy key.
2885    pub fn open_with_key(root: impl AsRef<Path>, key: &[u8]) -> Result<Self> {
2886        let (root, lock) = Self::begin_open(root, 0)?;
2887        let salt = read_encryption_salt(lock.durable_root.as_deref().ok_or_else(|| {
2888            MongrelError::Other("database root descriptor was not pinned".into())
2889        })?)?;
2890        let kek = Arc::new(crate::encryption::Kek::from_raw_key(key, &salt)?);
2891        Self::open_inner_locked(
2892            root,
2893            Some(kek),
2894            lock,
2895            CoreResourceConfig::default(),
2896            OpenModeGate::Normal,
2897        )
2898    }
2899
2900    /// Open a KMS-encrypted database. Provider identity must match the durable
2901    /// envelope and unwrapping fails closed.
2902    pub fn open_with_kms(
2903        root: impl AsRef<Path>,
2904        provider: &dyn crate::security_hardening::KeyManagementProvider,
2905    ) -> Result<Self> {
2906        Self::open_with_kms_and_options(root, provider, OpenOptions::default())
2907    }
2908
2909    /// Open a KMS-encrypted database with non-default open options.
2910    pub fn open_with_kms_and_options(
2911        root: impl AsRef<Path>,
2912        provider: &dyn crate::security_hardening::KeyManagementProvider,
2913        options: OpenOptions,
2914    ) -> Result<Self> {
2915        let (root, lock) = Self::begin_open(root, options.lock_timeout_ms)?;
2916        let kek = open_kms_kek(
2917            lock.durable_root.as_deref().ok_or_else(|| {
2918                MongrelError::Other("database root descriptor was not pinned".into())
2919            })?,
2920            provider,
2921        )?;
2922        let gate = if options.offline_validation {
2923            OpenModeGate::OfflineValidation
2924        } else {
2925            OpenModeGate::Normal
2926        };
2927        Self::open_inner_locked(
2928            root,
2929            Some(kek),
2930            lock,
2931            CoreResourceConfig::from_options(&options)?,
2932            gate,
2933        )
2934    }
2935
2936    /// Rewrap the database root key under another KMS key without stopping
2937    /// reads or writes. Data files stay encrypted under the same random root
2938    /// key; only its external envelope changes.
2939    pub fn rotate_kms_key(
2940        &self,
2941        provider: &dyn crate::security_hardening::KeyManagementProvider,
2942        new_kms_key_id: &str,
2943    ) -> Result<crate::security_hardening::KeyRotationRecord> {
2944        let _rotation_guard = self.core.kms_rotation_lock.lock();
2945        if new_kms_key_id.is_empty() {
2946            return Err(MongrelError::InvalidArgument(
2947                "KMS key id must not be empty".into(),
2948            ));
2949        }
2950        let durable_root = Arc::clone(&self.core.durable_root);
2951        let root = durable_root.io_path()?;
2952        let envelope = read_kms_key_envelope(&durable_root)?;
2953        if envelope.provider_id != provider.provider_id() {
2954            return Err(MongrelError::Encryption(format!(
2955                "KMS provider mismatch: database requires {:?}, got {:?}",
2956                envelope.provider_id,
2957                provider.provider_id()
2958            )));
2959        }
2960        let journal = crate::security_hardening::KeyRotationJournal::new(root.join(META_DIR));
2961        if let Some(record) = journal
2962            .load()
2963            .map_err(|error| MongrelError::Encryption(error.to_string()))?
2964            .filter(|record| record.phase != crate::security_hardening::KeyRotationPhase::Succeeded)
2965        {
2966            if record.to_key_id != new_kms_key_id {
2967                return Err(MongrelError::Conflict(format!(
2968                    "KMS rotation to {:?} is already in progress",
2969                    record.to_key_id
2970                )));
2971            }
2972            return self.run_kms_key_rotation(provider, &durable_root, &root, &journal, record);
2973        }
2974        let now_micros = std::time::SystemTime::now()
2975            .duration_since(std::time::UNIX_EPOCH)
2976            .unwrap_or_default()
2977            .as_micros() as u64;
2978        let mut record = crate::security_hardening::KeyRotationRecord::new(
2979            envelope.wrapped_key.key_version.clone(),
2980            String::new(),
2981            now_micros,
2982        );
2983        record.from_key_id = envelope.wrapped_key.kms_key_id.clone();
2984        record.to_key_id = new_kms_key_id.to_owned();
2985        persist_key_rotation(&journal, &record)?;
2986        self.run_kms_key_rotation(provider, &durable_root, &root, &journal, record)
2987    }
2988
2989    /// Retry a rotation that durably entered `Failed`.
2990    pub fn retry_kms_key_rotation(
2991        &self,
2992        provider: &dyn crate::security_hardening::KeyManagementProvider,
2993    ) -> Result<crate::security_hardening::KeyRotationRecord> {
2994        let _rotation_guard = self.core.kms_rotation_lock.lock();
2995        let durable_root = Arc::clone(&self.core.durable_root);
2996        let root = durable_root.io_path()?;
2997        let journal = crate::security_hardening::KeyRotationJournal::new(root.join(META_DIR));
2998        let mut record = journal
2999            .load()
3000            .map_err(|error| MongrelError::Encryption(error.to_string()))?
3001            .ok_or_else(|| MongrelError::NotFound("KMS rotation journal".into()))?;
3002        if record.phase != crate::security_hardening::KeyRotationPhase::Failed {
3003            return Err(MongrelError::Conflict(
3004                "KMS rotation is not in Failed state".into(),
3005            ));
3006        }
3007        record.retry();
3008        persist_key_rotation(&journal, &record)?;
3009        self.run_kms_key_rotation(provider, &durable_root, &root, &journal, record)
3010    }
3011
3012    fn run_kms_key_rotation(
3013        &self,
3014        provider: &dyn crate::security_hardening::KeyManagementProvider,
3015        durable_root: &crate::durable_file::DurableRoot,
3016        root: &Path,
3017        journal: &crate::security_hardening::KeyRotationJournal,
3018        mut record: crate::security_hardening::KeyRotationRecord,
3019    ) -> Result<crate::security_hardening::KeyRotationRecord> {
3020        use crate::security_hardening::{KeyRotationPhase, KmsDatabaseKeyEnvelope};
3021        loop {
3022            match record.phase {
3023                KeyRotationPhase::Pending => advance_key_rotation(journal, &mut record)?,
3024                KeyRotationPhase::WrappingNewKey => {
3025                    if record.staged_wrapped_key.is_none() {
3026                        let current = read_kms_key_envelope(durable_root)?;
3027                        let wrapped =
3028                            match provider.rewrap_key(&current.wrapped_key, &record.to_key_id) {
3029                                Ok(wrapped) => wrapped,
3030                                Err(error) => {
3031                                    return fail_key_rotation(journal, &mut record, error);
3032                                }
3033                            };
3034                        record.to_version = wrapped.key_version.clone();
3035                        record.staged_wrapped_key = Some(wrapped);
3036                        persist_key_rotation(journal, &record)?;
3037                    }
3038                    advance_key_rotation(journal, &mut record)?;
3039                }
3040                KeyRotationPhase::DualRead => {
3041                    let old_envelope = read_kms_key_envelope(durable_root)?;
3042                    let new_envelope = KmsDatabaseKeyEnvelope {
3043                        provider_id: provider.provider_id().to_owned(),
3044                        wrapped_key: record.staged_wrapped_key.clone().ok_or_else(|| {
3045                            MongrelError::Encryption(
3046                                "KMS rotation has no staged wrapped key".into(),
3047                            )
3048                        })?,
3049                    };
3050                    let old_raw = match unwrap_kms_database_key(provider, &old_envelope) {
3051                        Ok(key) => key,
3052                        Err(error) => {
3053                            return fail_key_rotation(journal, &mut record, error);
3054                        }
3055                    };
3056                    let new_raw = match unwrap_kms_database_key(provider, &new_envelope) {
3057                        Ok(key) => key,
3058                        Err(error) => {
3059                            return fail_key_rotation(journal, &mut record, error);
3060                        }
3061                    };
3062                    if !bool::from(old_raw.as_slice().ct_eq(new_raw.as_slice())) {
3063                        return fail_key_rotation(
3064                            journal,
3065                            &mut record,
3066                            "KMS rewrap changed the database root key",
3067                        );
3068                    }
3069                    advance_key_rotation(journal, &mut record)?;
3070                }
3071                KeyRotationPhase::Reencrypting => {
3072                    // Envelope rotation keeps the database root key stable, so
3073                    // pages, WAL records, and active readers need no rewrite.
3074                    advance_key_rotation(journal, &mut record)?;
3075                }
3076                KeyRotationPhase::Validating => {
3077                    let new_envelope = KmsDatabaseKeyEnvelope {
3078                        provider_id: provider.provider_id().to_owned(),
3079                        wrapped_key: record.staged_wrapped_key.clone().ok_or_else(|| {
3080                            MongrelError::Encryption(
3081                                "KMS rotation has no staged wrapped key".into(),
3082                            )
3083                        })?,
3084                    };
3085                    let new_raw = match unwrap_kms_database_key(provider, &new_envelope) {
3086                        Ok(key) => key,
3087                        Err(error) => {
3088                            return fail_key_rotation(journal, &mut record, error);
3089                        }
3090                    };
3091                    let salt = read_encryption_salt(durable_root)?;
3092                    let candidate = crate::encryption::Kek::from_raw_key(new_raw.as_ref(), &salt)?;
3093                    let candidate_meta = candidate.derive_meta_key();
3094                    let Some(active_meta) = self.core.meta_dek.as_ref() else {
3095                        return fail_key_rotation(
3096                            journal,
3097                            &mut record,
3098                            "KMS metadata exists on a plaintext database",
3099                        );
3100                    };
3101                    if !bool::from(candidate_meta.as_ref().ct_eq(active_meta.as_slice())) {
3102                        return fail_key_rotation(
3103                            journal,
3104                            &mut record,
3105                            "KMS rewrap validation failed",
3106                        );
3107                    }
3108                    advance_key_rotation(journal, &mut record)?;
3109                }
3110                KeyRotationPhase::Published => {
3111                    let envelope = KmsDatabaseKeyEnvelope {
3112                        provider_id: provider.provider_id().to_owned(),
3113                        wrapped_key: record.staged_wrapped_key.clone().ok_or_else(|| {
3114                            MongrelError::Encryption(
3115                                "KMS rotation has no staged wrapped key".into(),
3116                            )
3117                        })?,
3118                    };
3119                    write_kms_key_envelope(root, &envelope)?;
3120                    advance_key_rotation(journal, &mut record)?;
3121                }
3122                KeyRotationPhase::RetiringOldKey => {
3123                    advance_key_rotation(journal, &mut record)?;
3124                }
3125                KeyRotationPhase::Succeeded => return Ok(record),
3126                KeyRotationPhase::Failed => {
3127                    return Err(MongrelError::Encryption(
3128                        "failed KMS rotation must be explicitly retried".into(),
3129                    ));
3130                }
3131            }
3132        }
3133    }
3134
3135    /// Open an existing plaintext database that has `require_auth = true`,
3136    /// verifying the supplied credentials up front and caching the resolved
3137    /// [`Principal`] on the returned handle. Every subsequent operation will
3138    /// be checked against that principal.
3139    ///
3140    /// Returns [`MongrelError::AuthNotRequired`] if the database does not have
3141    /// `require_auth` enabled — callers must pick the matching constructor for
3142    /// the database's mode. Returns [`MongrelError::InvalidCredentials`] on a
3143    /// bad username/password.
3144    ///
3145    /// See `docs/15-credential-enforcement.md`.
3146    pub fn open_with_credentials(
3147        root: impl AsRef<Path>,
3148        username: &str,
3149        password: &str,
3150    ) -> Result<Self> {
3151        Self::open_inner_with_credentials(root, None, username, password)
3152    }
3153
3154    /// Open with credentials and a configurable cross-process lock timeout.
3155    /// Mirrors [`open_with_options`](Self::open_with_options) for the
3156    /// credentialed path.
3157    pub fn open_with_credentials_and_options(
3158        root: impl AsRef<Path>,
3159        username: &str,
3160        password: &str,
3161        options: OpenOptions,
3162    ) -> Result<Self> {
3163        let gate = if options.offline_validation {
3164            OpenModeGate::OfflineValidation
3165        } else {
3166            OpenModeGate::Normal
3167        };
3168        Self::open_inner_with_credentials_and_lock_timeout(
3169            root,
3170            None,
3171            username,
3172            password,
3173            options.lock_timeout_ms,
3174            CoreResourceConfig::from_options(&options)?,
3175            gate,
3176        )
3177    }
3178
3179    /// Open an existing encrypted database that has `require_auth = true`,
3180    /// combining the encryption passphrase flow with credential verification.
3181    pub fn open_encrypted_with_credentials(
3182        root: impl AsRef<Path>,
3183        passphrase: &str,
3184        username: &str,
3185        password: &str,
3186    ) -> Result<Self> {
3187        let (root, lock) = Self::begin_open(root, 0)?;
3188        let salt = read_encryption_salt(lock.durable_root.as_deref().ok_or_else(|| {
3189            MongrelError::Other("database root descriptor was not pinned".into())
3190        })?)?;
3191        let kek = Arc::new(crate::encryption::Kek::derive(passphrase, &salt)?);
3192        Self::open_inner_with_credentials_locked(
3193            root,
3194            Some(kek),
3195            username,
3196            password,
3197            lock,
3198            CoreResourceConfig::default(),
3199            OpenModeGate::Normal,
3200        )
3201    }
3202
3203    /// Open an encrypted + credentialed database with a configurable
3204    /// cross-process lock timeout. Mirrors
3205    /// [`open_encrypted_with_options`](Self::open_encrypted_with_options).
3206    pub fn open_encrypted_with_credentials_and_options(
3207        root: impl AsRef<Path>,
3208        passphrase: &str,
3209        username: &str,
3210        password: &str,
3211        options: OpenOptions,
3212    ) -> Result<Self> {
3213        let (root, lock) = Self::begin_open(root, options.lock_timeout_ms)?;
3214        let salt = read_encryption_salt(lock.durable_root.as_deref().ok_or_else(|| {
3215            MongrelError::Other("database root descriptor was not pinned".into())
3216        })?)?;
3217        let kek = Arc::new(crate::encryption::Kek::derive(passphrase, &salt)?);
3218        let gate = if options.offline_validation {
3219            OpenModeGate::OfflineValidation
3220        } else {
3221            OpenModeGate::Normal
3222        };
3223        Self::open_inner_with_credentials_locked(
3224            root,
3225            Some(kek),
3226            username,
3227            password,
3228            lock,
3229            CoreResourceConfig::from_options(&options)?,
3230            gate,
3231        )
3232    }
3233
3234    /// Open a KMS-encrypted database and authenticate the returned handle.
3235    pub fn open_with_kms_and_credentials(
3236        root: impl AsRef<Path>,
3237        provider: &dyn crate::security_hardening::KeyManagementProvider,
3238        username: &str,
3239        password: &str,
3240    ) -> Result<Self> {
3241        let (root, lock) = Self::begin_open(root, 0)?;
3242        let kek = open_kms_kek(
3243            lock.durable_root.as_deref().ok_or_else(|| {
3244                MongrelError::Other("database root descriptor was not pinned".into())
3245            })?,
3246            provider,
3247        )?;
3248        Self::open_inner_with_credentials_locked(
3249            root,
3250            Some(kek),
3251            username,
3252            password,
3253            lock,
3254            CoreResourceConfig::default(),
3255            OpenModeGate::Normal,
3256        )
3257    }
3258
3259    /// Open an existing database with non-default [`OpenOptions`].
3260    ///
3261    /// Use this when you need cross-process lock retries (`lock_timeout_ms`)
3262    /// rather than the fail-fast default. The other open constructors keep
3263    /// their previous defaults; use their `*_with_options` variants when they
3264    /// need the same timeout behavior.
3265    pub fn open_with_options(root: impl AsRef<Path>, options: OpenOptions) -> Result<Self> {
3266        // No encryption, no auth; encrypted and credentialed paths have their
3267        // own `*_with_options` constructors.
3268        let gate = if options.offline_validation {
3269            OpenModeGate::OfflineValidation
3270        } else {
3271            OpenModeGate::Normal
3272        };
3273        Self::open_inner_with_lock_timeout(
3274            root,
3275            None,
3276            None,
3277            options.lock_timeout_ms,
3278            CoreResourceConfig::from_options(&options)?,
3279            gate,
3280        )
3281    }
3282
3283    fn open_inner_with_lock_timeout(
3284        root: impl AsRef<Path>,
3285        kek: Option<Arc<crate::encryption::Kek>>,
3286        _meta_dek_override: Option<[u8; META_DEK_LEN]>,
3287        lock_timeout_ms: u32,
3288        resources: CoreResourceConfig,
3289        mode_gate: OpenModeGate,
3290    ) -> Result<Self> {
3291        let (root, lock) = Self::begin_open(root, lock_timeout_ms)?;
3292        Self::open_inner_locked(root, kek, lock, resources, mode_gate)
3293    }
3294
3295    fn open_inner_locked(
3296        root: PathBuf,
3297        kek: Option<Arc<crate::encryption::Kek>>,
3298        lock: ExclusiveDatabaseLease,
3299        resources: CoreResourceConfig,
3300        mode_gate: OpenModeGate,
3301    ) -> Result<Self> {
3302        let meta_dek = crate::encryption::meta_dek_for(kek.as_deref());
3303        let mut cat = catalog::read_durable(
3304            lock.durable_root.as_deref().ok_or_else(|| {
3305                MongrelError::Other("database root descriptor was not pinned".into())
3306            })?,
3307            meta_dek.as_ref(),
3308        )?
3309        .ok_or_else(|| MongrelError::NotFound(format!("no catalog found at {:?}", root)))?;
3310        let recovery_checkpoint = cat.clone();
3311
3312        // CATALOG is only a checkpoint. Authentication must use the
3313        // authoritative catalog after committed WAL DDL/security replay.
3314        let wal_dek = crate::encryption::wal_dek_for(kek.as_deref());
3315        let recovery_records = crate::wal::SharedWal::replay_durable_with_dek_limited(
3316            lock.durable_root.as_deref().ok_or_else(|| {
3317                MongrelError::Other("database root descriptor was not pinned".into())
3318            })?,
3319            wal_dek.as_ref(),
3320            resources.wal_replay_limits,
3321        )?;
3322        recover_ddl_from_records(
3323            &root,
3324            Some(lock.durable_root.as_deref().ok_or_else(|| {
3325                MongrelError::Other("database root descriptor was not pinned".into())
3326            })?),
3327            &mut cat,
3328            meta_dek.as_ref(),
3329            false,
3330            None,
3331            &recovery_records,
3332        )?;
3333        Self::finish_open(
3334            root,
3335            cat,
3336            kek,
3337            meta_dek,
3338            true,
3339            Some(recovery_checkpoint),
3340            Some(recovery_records),
3341            None,
3342            lock,
3343            resources,
3344            mode_gate,
3345        )
3346    }
3347
3348    /// Shared credentialed-open inner: read the catalog, verify the database
3349    /// requires auth, verify the password, resolve the principal, and pass
3350    /// everything to `finish_open` in one shot. This avoids the chicken-and-egg
3351    /// problem where `finish_open`'s fail-closed check (`require_auth &&
3352    /// principal.is_none()`) would fire before a post-open `authenticate()`
3353    /// could supply the principal.
3354    fn open_inner_with_credentials(
3355        root: impl AsRef<Path>,
3356        kek: Option<Arc<crate::encryption::Kek>>,
3357        username: &str,
3358        password: &str,
3359    ) -> Result<Self> {
3360        Self::open_inner_with_credentials_and_lock_timeout(
3361            root,
3362            kek,
3363            username,
3364            password,
3365            0,
3366            CoreResourceConfig::default(),
3367            OpenModeGate::Normal,
3368        )
3369    }
3370
3371    /// Credentialed-open with an explicit cross-process lock timeout. The
3372    /// timeout is opt-in: callers that don't pass `OpenOptions` keep the
3373    /// historical fail-fast behavior via the wrapper above.
3374    #[allow(clippy::too_many_arguments)]
3375    fn open_inner_with_credentials_and_lock_timeout(
3376        root: impl AsRef<Path>,
3377        kek: Option<Arc<crate::encryption::Kek>>,
3378        username: &str,
3379        password: &str,
3380        lock_timeout_ms: u32,
3381        resources: CoreResourceConfig,
3382        mode_gate: OpenModeGate,
3383    ) -> Result<Self> {
3384        let (root, lock) = Self::begin_open(root, lock_timeout_ms)?;
3385        Self::open_inner_with_credentials_locked(
3386            root, kek, username, password, lock, resources, mode_gate,
3387        )
3388    }
3389
3390    #[allow(clippy::too_many_arguments)]
3391    fn open_inner_with_credentials_locked(
3392        root: PathBuf,
3393        kek: Option<Arc<crate::encryption::Kek>>,
3394        username: &str,
3395        password: &str,
3396        lock: ExclusiveDatabaseLease,
3397        resources: CoreResourceConfig,
3398        mode_gate: OpenModeGate,
3399    ) -> Result<Self> {
3400        let meta_dek = crate::encryption::meta_dek_for(kek.as_deref());
3401        let mut cat = catalog::read_durable(
3402            lock.durable_root.as_deref().ok_or_else(|| {
3403                MongrelError::Other("database root descriptor was not pinned".into())
3404            })?,
3405            meta_dek.as_ref(),
3406        )?
3407        .ok_or_else(|| MongrelError::NotFound(format!("no catalog found at {:?}", root)))?;
3408        let recovery_checkpoint = cat.clone();
3409
3410        // Never verify against a stale checkpoint. A committed password,
3411        // user, role, or auth-mode change in WAL is authoritative.
3412        let wal_dek = crate::encryption::wal_dek_for(kek.as_deref());
3413        let recovery_records = crate::wal::SharedWal::replay_durable_with_dek_limited(
3414            lock.durable_root.as_deref().ok_or_else(|| {
3415                MongrelError::Other("database root descriptor was not pinned".into())
3416            })?,
3417            wal_dek.as_ref(),
3418            resources.wal_replay_limits,
3419        )?;
3420        recover_ddl_from_records(
3421            &root,
3422            Some(lock.durable_root.as_deref().ok_or_else(|| {
3423                MongrelError::Other("database root descriptor was not pinned".into())
3424            })?),
3425            &mut cat,
3426            meta_dek.as_ref(),
3427            false,
3428            None,
3429            &recovery_records,
3430        )?;
3431
3432        // Fail early if the database is not in require_auth mode — the caller
3433        // picked the wrong constructor.
3434        if !cat.require_auth {
3435            return Err(MongrelError::AuthNotRequired);
3436        }
3437
3438        // Verify credentials against the on-disk catalog before constructing
3439        // the full Database handle. This reads users/hashes directly from the
3440        // loaded catalog rather than going through the Database::verify_user
3441        // method (which requires a constructed Database).
3442        let user = cat
3443            .users
3444            .iter()
3445            .find(|u| u.username == username)
3446            .filter(|u| !u.password_hash.is_empty())
3447            .ok_or_else(|| MongrelError::InvalidCredentials {
3448                username: username.to_string(),
3449            })?;
3450        let password_ok = crate::auth::verify_password(password, &user.password_hash)
3451            .map_err(MongrelError::Other)?;
3452        if !password_ok {
3453            return Err(MongrelError::InvalidCredentials {
3454                username: username.to_string(),
3455            });
3456        }
3457
3458        // Resolve the principal from the catalog (roles + permissions).
3459        let principal =
3460            Self::resolve_principal_from_catalog(&cat, &user.username).ok_or_else(|| {
3461                MongrelError::InvalidCredentials {
3462                    username: username.to_string(),
3463                }
3464            })?;
3465
3466        Self::finish_open(
3467            root,
3468            cat,
3469            kek,
3470            meta_dek,
3471            true,
3472            Some(recovery_checkpoint),
3473            Some(recovery_records),
3474            Some(principal),
3475            lock,
3476            resources,
3477            mode_gate,
3478        )
3479    }
3480
3481    /// Create a fresh plaintext database with `require_auth = true` and a
3482    /// single admin user. The returned handle is already authenticated as
3483    /// that admin — every subsequent operation is checked against the admin
3484    /// principal (which bypasses all permission checks via `is_admin`).
3485    ///
3486    /// This is the bootstrap path: there is no window where the database
3487    /// requires auth but has no users.
3488    ///
3489    /// See `docs/15-credential-enforcement.md`.
3490    pub fn create_with_credentials(
3491        root: impl AsRef<Path>,
3492        admin_username: &str,
3493        admin_password: &str,
3494    ) -> Result<Self> {
3495        let (root, lock) = Self::begin_create(root)?;
3496        Self::create_inner_with_credentials(root, None, admin_username, admin_password, lock)
3497    }
3498
3499    /// Create a fresh encrypted database with `require_auth = true` and a
3500    /// single admin user. Composes encryption-at-rest with credential
3501    /// enforcement.
3502    pub fn create_encrypted_with_credentials(
3503        root: impl AsRef<Path>,
3504        passphrase: &str,
3505        admin_username: &str,
3506        admin_password: &str,
3507    ) -> Result<Self> {
3508        let (root, lock) = Self::begin_create(root)?;
3509        let salt = crate::encryption::random_salt()?;
3510        crate::durable_file::write_atomic(&root.join(META_DIR).join(KEYS_FILENAME), &salt)?;
3511        let kek = Arc::new(crate::encryption::Kek::derive(passphrase, &salt)?);
3512        Self::create_inner_with_credentials(root, Some(kek), admin_username, admin_password, lock)
3513    }
3514
3515    /// Create a KMS-encrypted database with an authenticated admin handle.
3516    pub fn create_with_kms_and_credentials(
3517        root: impl AsRef<Path>,
3518        provider: &dyn crate::security_hardening::KeyManagementProvider,
3519        kms_key_id: &str,
3520        admin_username: &str,
3521        admin_password: &str,
3522    ) -> Result<Self> {
3523        let (root, lock) = Self::begin_create(root)?;
3524        let kek = create_kms_kek(&root, provider, kms_key_id)?;
3525        Self::create_inner_with_credentials(root, Some(kek), admin_username, admin_password, lock)
3526    }
3527
3528    fn create_inner_with_credentials(
3529        root: PathBuf,
3530        kek: Option<Arc<crate::encryption::Kek>>,
3531        admin_username: &str,
3532        admin_password: &str,
3533        lock: ExclusiveDatabaseLease,
3534    ) -> Result<Self> {
3535        crate::durable_file::create_directory_all(&root.join(TABLES_DIR))?;
3536        let meta_dek = crate::encryption::meta_dek_for(kek.as_deref());
3537
3538        // Build the initial catalog with require_auth = true and one admin user.
3539        let password_hash =
3540            crate::auth::hash_password(admin_password).map_err(MongrelError::Other)?;
3541        let scram_sha_256 =
3542            crate::auth::scram_verifier(admin_password).map_err(MongrelError::Other)?;
3543        let mysql_caching_sha2 =
3544            crate::auth::MysqlCachingSha2Verifier::from_password(admin_password);
3545        let mut cat = Catalog::empty();
3546        cat.require_auth = true;
3547        cat.next_user_id = 2;
3548        cat.users.push(crate::auth::UserEntry {
3549            id: 1,
3550            username: admin_username.to_string(),
3551            password_hash,
3552            scram_sha_256: Some(scram_sha_256),
3553            mysql_caching_sha2: Some(mysql_caching_sha2),
3554            roles: Vec::new(),
3555            is_admin: true,
3556            created_epoch: 0,
3557        });
3558        catalog::write_atomic(&root, &cat, meta_dek.as_ref())?;
3559
3560        // The handle is constructed already authenticated as the admin user
3561        // it just created — no separate verify step needed.
3562        let admin_principal = crate::auth::Principal {
3563            user_id: 1,
3564            created_epoch: 0,
3565            username: admin_username.to_string(),
3566            is_admin: true,
3567            roles: Vec::new(),
3568            permissions: Vec::new(),
3569        };
3570        Self::finish_open(
3571            root,
3572            cat,
3573            kek,
3574            meta_dek,
3575            false,
3576            None,
3577            None,
3578            Some(admin_principal),
3579            lock,
3580            CoreResourceConfig::default(),
3581            OpenModeGate::Create(crate::storage_mode::StorageMode::Standalone),
3582        )
3583    }
3584
3585    fn reject_existing_database(root: &Path) -> Result<()> {
3586        // Refuse to overwrite an existing database. If CATALOG exists, the
3587        // directory already contains a real database; replacing it destroys data.
3588        if root.join(catalog::CATALOG_FILENAME).exists() {
3589            return Err(MongrelError::InvalidArgument(format!(
3590                "database already exists at {}; use Database::open() to open it, \
3591                 or remove the directory first",
3592                root.display()
3593            )));
3594        }
3595        Ok(())
3596    }
3597
3598    fn open_inner(
3599        root: impl AsRef<Path>,
3600        kek: Option<Arc<crate::encryption::Kek>>,
3601        _meta_dek_override: Option<[u8; META_DEK_LEN]>,
3602    ) -> Result<Self> {
3603        Self::open_inner_with_lock_timeout(
3604            root,
3605            kek,
3606            None,
3607            0,
3608            CoreResourceConfig::default(),
3609            OpenModeGate::Normal,
3610        )
3611    }
3612
3613    /// Open an existing plaintext database through the special
3614    /// offline-validation API (spec section 5.3): any storage mode, read-only.
3615    pub(crate) fn open_offline_validation(root: impl AsRef<Path>) -> Result<Self> {
3616        Self::open_inner_with_lock_timeout(
3617            root,
3618            None,
3619            None,
3620            0,
3621            CoreResourceConfig::default(),
3622            OpenModeGate::OfflineValidation,
3623        )
3624    }
3625
3626    /// Internal recovery open for a staging directory explicitly marked as a
3627    /// read-only replica. It bypasses user authentication only so PITR can
3628    /// replay auth-mode and password transitions; it is not public API.
3629    pub(crate) fn open_replica_recovery_durable(
3630        root: &crate::durable_file::DurableRoot,
3631    ) -> Result<Self> {
3632        let (root, lock) = Self::begin_open_durable(root.try_clone()?, 0)?;
3633        Self::open_replica_recovery_inner(root, None, lock)
3634    }
3635
3636    pub(crate) fn open_encrypted_replica_recovery_durable(
3637        root: &crate::durable_file::DurableRoot,
3638        passphrase: &str,
3639    ) -> Result<Self> {
3640        let (root_path, lock) = Self::begin_open_durable(root.try_clone()?, 0)?;
3641        let salt = read_encryption_salt(root)?;
3642        let kek = Arc::new(crate::encryption::Kek::derive(passphrase, &salt)?);
3643        Self::open_replica_recovery_inner(root_path, Some(kek), lock)
3644    }
3645
3646    fn open_replica_recovery_inner(
3647        root: PathBuf,
3648        kek: Option<Arc<crate::encryption::Kek>>,
3649        lock: ExclusiveDatabaseLease,
3650    ) -> Result<Self> {
3651        if !root.join(META_DIR).join("replica").is_file() {
3652            return Err(MongrelError::InvalidArgument(
3653                "recovery auth bypass requires a marked replica staging directory".into(),
3654            ));
3655        }
3656        let meta_dek = crate::encryption::meta_dek_for(kek.as_deref());
3657        let mut cat = catalog::read_durable(
3658            lock.durable_root.as_deref().ok_or_else(|| {
3659                MongrelError::Other("database root descriptor was not pinned".into())
3660            })?,
3661            meta_dek.as_ref(),
3662        )?
3663        .ok_or_else(|| MongrelError::NotFound(format!("no catalog found at {:?}", root)))?;
3664        let recovery_checkpoint = cat.clone();
3665        let wal_dek = crate::encryption::wal_dek_for(kek.as_deref());
3666        let recovery_records = crate::wal::SharedWal::replay_durable_with_dek_limited(
3667            lock.durable_root.as_deref().ok_or_else(|| {
3668                MongrelError::Other("database root descriptor was not pinned".into())
3669            })?,
3670            wal_dek.as_ref(),
3671            crate::wal::WalReplayLimits::from_env()?,
3672        )?;
3673        recover_ddl_from_records(
3674            &root,
3675            Some(lock.durable_root.as_deref().ok_or_else(|| {
3676                MongrelError::Other("database root descriptor was not pinned".into())
3677            })?),
3678            &mut cat,
3679            meta_dek.as_ref(),
3680            false,
3681            None,
3682            &recovery_records,
3683        )?;
3684        let principal = if cat.require_auth {
3685            cat.users
3686                .iter()
3687                .find(|user| user.is_admin)
3688                .and_then(|user| Self::resolve_principal_from_catalog(&cat, &user.username))
3689                .ok_or_else(|| {
3690                    MongrelError::Schema(
3691                        "authenticated replica catalog has no recoverable admin".into(),
3692                    )
3693                })?
3694                .into()
3695        } else {
3696            None
3697        };
3698        Self::finish_open(
3699            root,
3700            cat,
3701            kek,
3702            meta_dek,
3703            true,
3704            Some(recovery_checkpoint),
3705            Some(recovery_records),
3706            principal,
3707            lock,
3708            CoreResourceConfig::default(),
3709            OpenModeGate::Normal,
3710        )
3711    }
3712
3713    /// Acquire an exclusive advisory lock on `f`, retrying on `EAGAIN`/`EWOULDBLOCK`
3714    /// until `timeout_ms` elapses, mirroring SQLite's `busy_timeout` semantics.
3715    ///
3716    /// `timeout_ms == 0` is the fail-fast path: a single `try_lock_exclusive` call,
3717    /// no retry, no sleep. Existing open paths rely on that fail-fast default for
3718    /// backwards compatibility — opt in with `OpenOptions::lock_timeout_ms`.
3719    ///
3720    /// Backoff schedule: 1ms → 10ms → 50ms → 50ms → ... until `timeout_ms`.
3721    /// Total elapsed (not just sleep time) is bounded by `timeout_ms`, so the
3722    /// caller never blocks past its budget even at the tail of a busy lock
3723    /// holder's lock-window.
3724    fn fs_lock_exclusive(f: &std::fs::File, timeout_ms: u32) -> std::io::Result<()> {
3725        use fs2::FileExt;
3726        if timeout_ms == 0 {
3727            return f.try_lock_exclusive();
3728        }
3729        // Per-call deadline so a single stray 50ms sleep can't overshoot the budget.
3730        let deadline =
3731            std::time::Instant::now() + std::time::Duration::from_millis(timeout_ms as u64);
3732        let mut next_sleep = std::time::Duration::from_millis(1);
3733        let mut recorded_wait = false;
3734        loop {
3735            match f.try_lock_exclusive() {
3736                Ok(()) => return Ok(()),
3737                Err(e) if Self::is_file_lock_contended(&e) => {
3738                    if !recorded_wait {
3739                        DATABASE_OPEN_WAIT_COUNT.fetch_add(1, Ordering::Relaxed);
3740                        recorded_wait = true;
3741                    }
3742                    let now = std::time::Instant::now();
3743                    if now >= deadline {
3744                        return Err(std::io::Error::new(
3745                            std::io::ErrorKind::WouldBlock,
3746                            format!("could not acquire database lock within {timeout_ms}ms"),
3747                        ));
3748                    }
3749                    let remaining = deadline - now;
3750                    let sleep = next_sleep.min(remaining);
3751                    std::thread::sleep(sleep);
3752                    // Cap the per-iteration sleep so a single back-off step
3753                    // never overshoots the remaining budget.
3754                    next_sleep = next_sleep
3755                        .saturating_mul(10)
3756                        .min(std::time::Duration::from_millis(50));
3757                }
3758                Err(e) => return Err(e),
3759            }
3760        }
3761    }
3762
3763    fn is_file_lock_contended(error: &std::io::Error) -> bool {
3764        if error.kind() == std::io::ErrorKind::WouldBlock {
3765            return true;
3766        }
3767        #[cfg(windows)]
3768        {
3769            // LockFileEx reports ERROR_LOCK_VIOLATION without mapping it to
3770            // ErrorKind::WouldBlock.
3771            return error.raw_os_error() == Some(33);
3772        }
3773        #[cfg(not(windows))]
3774        false
3775    }
3776
3777    #[allow(clippy::too_many_arguments)]
3778    fn finish_open(
3779        root: PathBuf,
3780        cat: Catalog,
3781        kek: Option<Arc<crate::encryption::Kek>>,
3782        meta_dek: Option<[u8; META_DEK_LEN]>,
3783        existing: bool,
3784        recovery_checkpoint: Option<Catalog>,
3785        recovery_records: Option<Vec<crate::wal::Record>>,
3786        principal: Option<crate::auth::Principal>,
3787        lock: ExclusiveDatabaseLease,
3788        resources: CoreResourceConfig,
3789        mode_gate: OpenModeGate,
3790    ) -> Result<Self> {
3791        let durable_root = Arc::clone(lock.durable_root.as_ref().ok_or_else(|| {
3792            MongrelError::Other("database root descriptor was not pinned".into())
3793        })?);
3794        // Storage-mode gate (spec section 5.3, Stage 2E): read the durable
3795        // marker before any recovery side effect and fail closed on corrupt
3796        // or forbidden modes. Legacy databases have no marker and are treated
3797        // as Standalone (backfilled below, after recovery succeeds).
3798        let storage_mode = if existing {
3799            crate::storage_mode::read(&durable_root)?
3800        } else {
3801            None
3802        };
3803        match (&mode_gate, &storage_mode) {
3804            (OpenModeGate::Create(_), _) => {}
3805            (OpenModeGate::Normal | OpenModeGate::SharedCore, mode) => {
3806                crate::storage_mode::check_open(mode.as_ref(), false)?
3807            }
3808            (OpenModeGate::OfflineValidation, mode) => {
3809                crate::storage_mode::check_open(mode.as_ref(), true)?
3810            }
3811            (
3812                OpenModeGate::ClusterRuntime {
3813                    cluster_id,
3814                    node_id,
3815                    database_id,
3816                },
3817                Some(actual),
3818            ) => {
3819                let expected = crate::storage_mode::StorageMode::ClusterReplica {
3820                    cluster_id: *cluster_id,
3821                    node_id: *node_id,
3822                    database_id: *database_id,
3823                };
3824                if *actual != expected {
3825                    return Err(
3826                        crate::storage_mode::StorageModeError::IdentityMismatch(format!(
3827                            "expected {expected:?}, marker holds {actual:?}"
3828                        ))
3829                        .into(),
3830                    );
3831                }
3832            }
3833            (OpenModeGate::ClusterRuntime { .. }, None) => {
3834                return Err(crate::storage_mode::StorageModeError::IdentityMismatch(
3835                    "expected a ClusterReplica marker; directory has none".into(),
3836                )
3837                .into());
3838            }
3839        }
3840        let read_only = match &mode_gate {
3841            OpenModeGate::OfflineValidation | OpenModeGate::ClusterRuntime { .. } => true,
3842            // A freshly created cluster replica rejects user writes from the
3843            // start: mutations arrive through the replicated apply path only.
3844            OpenModeGate::Create(mode) => mode.cluster_identity().is_some(),
3845            OpenModeGate::Normal | OpenModeGate::SharedCore => false,
3846        } || if existing {
3847            match durable_root.open_regular(Path::new(META_DIR).join("replica")) {
3848                Ok(_) => true,
3849                Err(error) if error.kind() == std::io::ErrorKind::NotFound => false,
3850                Err(error) => return Err(error.into()),
3851            }
3852        } else {
3853            false
3854        };
3855        let recovered_catalog = cat;
3856        let mut cat = recovered_catalog.clone();
3857        let abandoned = if existing && !read_only {
3858            let abandoned = cat
3859                .tables
3860                .iter()
3861                .filter(|entry| matches!(entry.state, TableState::Building { .. }))
3862                .map(|entry| entry.table_id)
3863                .collect::<Vec<_>>();
3864            for entry in &mut cat.tables {
3865                if abandoned.contains(&entry.table_id) {
3866                    entry.state = TableState::Dropped {
3867                        at_epoch: cat.db_epoch,
3868                    };
3869                }
3870            }
3871            abandoned
3872        } else {
3873            Vec::new()
3874        };
3875        let wal_dek = crate::encryption::wal_dek_for(kek.as_deref());
3876        let recovery_records = match (existing, recovery_records) {
3877            (true, Some(records)) => records,
3878            (true, None) => {
3879                return Err(MongrelError::Other(
3880                    "existing open has no validated WAL recovery plan".into(),
3881                ))
3882            }
3883            (false, _) => Vec::new(),
3884        };
3885        let (history_epochs, history_start) =
3886            read_history_retention(&durable_root, Epoch(cat.db_epoch))?;
3887        let open_generation = if existing {
3888            let checkpoint = recovery_checkpoint.as_ref().ok_or_else(|| {
3889                MongrelError::Other("existing open has no catalog recovery checkpoint".into())
3890            })?;
3891            let recovered_table_ids = cat
3892                .tables
3893                .iter()
3894                .filter(|entry| {
3895                    checkpoint
3896                        .tables
3897                        .iter()
3898                        .all(|checkpoint| checkpoint.table_id != entry.table_id)
3899                })
3900                .map(|entry| entry.table_id)
3901                .collect::<HashSet<_>>();
3902            let reconciled_table_ids = cat
3903                .tables
3904                .iter()
3905                .filter(|entry| {
3906                    checkpoint
3907                        .tables
3908                        .iter()
3909                        .find(|checkpoint| checkpoint.table_id == entry.table_id)
3910                        .is_some_and(|checkpoint| {
3911                            crate::wal::DdlOp::encode_schema(&checkpoint.schema).ok()
3912                                != crate::wal::DdlOp::encode_schema(&entry.schema).ok()
3913                        })
3914                })
3915                .map(|entry| entry.table_id)
3916                .collect::<HashSet<_>>();
3917            validate_shared_wal_recovery_plan(
3918                &durable_root,
3919                &cat,
3920                &recovered_table_ids,
3921                &reconciled_table_ids,
3922                meta_dek.as_ref(),
3923                kek.clone(),
3924                &recovery_records,
3925            )?;
3926            let retained_generation = recovery_records
3927                .iter()
3928                .filter(|record| record.txn_id != crate::wal::SYSTEM_TXN_ID)
3929                .map(|record| record.txn_id >> 32)
3930                .max()
3931                .unwrap_or(0);
3932            let head_generation =
3933                crate::wal::SharedWal::durable_open_generation(&durable_root, wal_dek.as_ref())?;
3934            let durable_floor = match head_generation {
3935                Some(head) if retained_generation > head => {
3936                    return Err(MongrelError::CorruptWal {
3937                        offset: retained_generation,
3938                        reason: format!(
3939                            "retained transaction generation {retained_generation} exceeds WAL head generation {head}"
3940                        ),
3941                    })
3942                }
3943                Some(head) => head,
3944                None => retained_generation,
3945            };
3946            let stored = catalog::read_generation(&durable_root)?;
3947            if stored.is_some_and(|generation| generation < durable_floor) {
3948                return Err(MongrelError::Other(format!(
3949                    "open-generation {stored:?} precedes durable WAL generation {durable_floor}"
3950                )));
3951            }
3952            let bumped = stored
3953                .unwrap_or(durable_floor)
3954                .max(durable_floor)
3955                .checked_add(1)
3956                .ok_or_else(|| MongrelError::Full("open-generation namespace exhausted".into()))?;
3957            if bumped > u32::MAX as u64 {
3958                return Err(MongrelError::Full(
3959                    "open-generation namespace exhausted".into(),
3960                ));
3961            }
3962            bumped
3963        } else {
3964            0
3965        };
3966        let principal = if cat.require_auth && !matches!(&mode_gate, OpenModeGate::SharedCore) {
3967            let supplied = principal.as_ref().ok_or(MongrelError::AuthRequired)?;
3968            Some(
3969                Self::resolve_bound_principal_from_catalog(&cat, supplied)
3970                    .ok_or(MongrelError::AuthRequired)?,
3971            )
3972        } else {
3973            principal
3974        };
3975        let mut table_roots = HashMap::<u64, Arc<crate::durable_file::DurableRoot>>::new();
3976        if existing {
3977            for entry in &cat.tables {
3978                if !matches!(entry.state, TableState::Live) {
3979                    continue;
3980                }
3981                match durable_root
3982                    .open_directory(Path::new(TABLES_DIR).join(entry.table_id.to_string()))
3983                {
3984                    Ok(root) => {
3985                        table_roots.insert(entry.table_id, Arc::new(root));
3986                    }
3987                    Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
3988                    Err(error) => return Err(error.into()),
3989                }
3990            }
3991        }
3992
3993        // No database-tree mutation occurs above this point. DDL, row payloads,
3994        // immutable runs, auth state, retention, and generation state have all
3995        // been validated against the authoritative recovered catalog.
3996        if existing {
3997            let mut applied = recovery_checkpoint.ok_or_else(|| {
3998                MongrelError::Other("existing open has no catalog recovery checkpoint".into())
3999            })?;
4000            recover_ddl_from_records(
4001                &root,
4002                Some(&durable_root),
4003                &mut applied,
4004                meta_dek.as_ref(),
4005                true,
4006                Some(&table_roots),
4007                &recovery_records,
4008            )?;
4009            let catalog_value = |catalog: &Catalog| {
4010                serde_json::to_value(catalog)
4011                    .map_err(|error| MongrelError::Other(format!("catalog compare: {error}")))
4012            };
4013            if catalog_value(&applied)? != catalog_value(&recovered_catalog)? {
4014                return Err(MongrelError::CorruptWal {
4015                    offset: 0,
4016                    reason: "validated and applied DDL recovery plans differ".into(),
4017                });
4018            }
4019            if catalog_value(&cat)? != catalog_value(&applied)? {
4020                catalog::write_atomic(&root, &cat, meta_dek.as_ref())?;
4021            }
4022            validate_catalog_table_storage(&durable_root, &cat, meta_dek.as_ref())?;
4023            if !read_only {
4024                sweep_unreferenced_table_dirs(&root, &cat)?;
4025            }
4026            match durable_root.remove_directory_all(Path::new(META_DIR).join("backup-pins")) {
4027                Ok(()) => {}
4028                Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
4029                Err(error) => return Err(error.into()),
4030            }
4031        }
4032
4033        let epoch = Arc::new(EpochAuthority::new(cat.db_epoch));
4034        let snapshots = Arc::new(SnapshotRegistry::new());
4035        snapshots.configure_history(history_epochs, history_start);
4036        // S1E-003: exactly one node-level memory governor per storage core.
4037        // Both caches reserve under it (their live bytes always show up in
4038        // the governor's accounting) and register as reclaimable subsystems
4039        // the governor drives under escalation step 2.
4040        let memory_governor = crate::memory::MemoryGovernor::new(
4041            crate::memory::GovernorConfig::new(resources.memory_budget_bytes),
4042        )
4043        .map_err(|error| MongrelError::InvalidArgument(format!("memory governor: {error}")))?;
4044        // S1E-004: exactly one spill manager per core, rooted at
4045        // `<db-root>/temp/spill`; opening it runs the startup sweep that
4046        // deletes every stale spill file a prior process run left behind.
4047        let spill_manager = crate::spill::SpillManager::open(
4048            &durable_root,
4049            crate::spill::SpillConfig::new(resources.temp_disk_budget_bytes),
4050            meta_dek,
4051        )?;
4052        // S1F-002: exactly one durable job registry per core (sibling `JOBS`
4053        // file). Crash recovery inside `open` parks mid-`Running` jobs as
4054        // `Paused` for an operator-driven resume.
4055        let job_registry = Arc::new(crate::jobs::JobRegistry::open(&root, meta_dek.as_ref())?);
4056        let page_cache = Arc::new(crate::cache::Sharded::new(
4057            crate::cache::CACHE_SHARDS,
4058            || {
4059                crate::cache::PageCache::new(
4060                    crate::engine::PAGE_CACHE_CAPACITY / crate::cache::CACHE_SHARDS as u64,
4061                )
4062                .with_governor(
4063                    memory_governor.clone(),
4064                    crate::memory::MemoryClass::PageCache,
4065                )
4066            },
4067        ));
4068        memory_governor.register_reclaimable(&page_cache);
4069        let decoded_cache = Arc::new(crate::cache::Sharded::new(
4070            crate::cache::CACHE_SHARDS,
4071            || {
4072                crate::cache::DecodedPageCache::new(
4073                    crate::engine::DECODED_CACHE_CAPACITY / crate::cache::CACHE_SHARDS as u64,
4074                )
4075                .with_governor(
4076                    memory_governor.clone(),
4077                    crate::memory::MemoryClass::DecodedCache,
4078                )
4079            },
4080        ));
4081        memory_governor.register_reclaimable(&decoded_cache);
4082        let commit_lock = Arc::new(Mutex::new(()));
4083        let shared_wal = Arc::new(Mutex::new(if existing {
4084            let (_, layout) =
4085                crate::wal::SharedWal::inspect_durable_layout(&durable_root, wal_dek.as_ref())?;
4086            let planned = crate::wal::WalOpenContinuation {
4087                fingerprint: layout.fingerprint(),
4088                last_seq: recovery_records
4089                    .last()
4090                    .map(|record| record.seq.0)
4091                    .unwrap_or(cat.db_epoch),
4092                retained_generation: recovery_records
4093                    .iter()
4094                    .filter(|record| record.txn_id != crate::wal::SYSTEM_TXN_ID)
4095                    .map(|record| record.txn_id >> 32)
4096                    .max()
4097                    .unwrap_or(0),
4098            };
4099            crate::wal::SharedWal::open_durable_root_validated(
4100                Arc::clone(&durable_root),
4101                Epoch(cat.db_epoch),
4102                wal_dek.clone(),
4103                Some(&planned),
4104            )?
4105        } else {
4106            crate::wal::SharedWal::create_with_durable_root(
4107                Arc::clone(&durable_root),
4108                Epoch(cat.db_epoch),
4109                wal_dek.clone(),
4110            )?
4111        }));
4112        // Shared write-path state handed to every mounted table so single-table
4113        // `put`/`commit` writes route through the one shared WAL, the one group-
4114        // commit coordinator, and the one poison flag (B1).
4115        let poisoned = Arc::new(std::sync::atomic::AtomicBool::new(false));
4116        // S1A-004: the lifecycle is created before the write-path plumbing so
4117        // the group-commit coordinator and every mounted table can poison it on
4118        // an unrecoverable fsync error; `mark_open` happens only once every
4119        // initialization step below has completed.
4120        let lifecycle = Arc::new(crate::core::LifecycleController::new());
4121        let group = Arc::new(
4122            crate::txn::GroupCommit::new(shared_wal.lock().durable_seq())
4123                .with_lifecycle(Arc::clone(&lifecycle)),
4124        );
4125        let (change_wake, _change_rx) = tokio::sync::broadcast::channel(256);
4126        // Final base value is set after the open-generation bump below; tables
4127        // only draw ids once the user issues a write (post-open), so the
4128        // placeholder is never observed.
4129        let txn_ids = Arc::new(Mutex::new(1u64));
4130        let _ = abandoned;
4131        // FND-004 (spec §9.4): the commit-log authority for this database. The
4132        // transaction-id allocator Arc is stable; its base value is re-seeded
4133        // below once the open generation is final. The HLC clock is the node's
4134        // single timestamp authority (spec §4.1, §8.2; ADR-0003): standalone
4135        // mode uses node tiebreaker 0, and the skew bound only engages once
4136        // remote timestamps are observed (Stage 2 replication).
4137        let hlc = Arc::new(mongreldb_types::hlc::HlcClock::new(
4138            0,
4139            std::time::Duration::from_millis(500),
4140        ));
4141        // S1B-005: durable idempotency ledger (sibling `TXN_IDEMPOTENCY` file
4142        // via `DurableRoot::write_atomic`, mirroring `jobs.rs`'s `JOBS`).
4143        let idempotency = crate::txn::IdempotencyLedger::open(Arc::clone(&durable_root), meta_dek)?;
4144        let standalone_commit_log = Arc::new(crate::commit_log::StandaloneCommitLog::new(
4145            Arc::clone(&shared_wal),
4146            Arc::clone(&group),
4147            Arc::clone(&epoch),
4148            Arc::clone(&commit_lock),
4149            Arc::clone(&txn_ids),
4150            root.clone(),
4151            wal_dek.clone(),
4152            Arc::clone(&hlc),
4153        ));
4154        let commit_log: Arc<dyn mongreldb_log::CommitLog> = standalone_commit_log.clone();
4155
4156        // Build the shared auth state early — it's cloned into every mounted
4157        // Table's SharedCtx so the Table layer can enforce permissions without
4158        // a reference back to Database. The `require_auth` flag is mirrored
4159        // from the catalog; `enable_auth` / `refresh_principal` update it live.
4160        let auth_state = crate::auth_state::AuthState::new(cat.require_auth, principal.clone());
4161        let security_coordinator = security_coordinator(&root, cat.security_version);
4162        let auth_checker: Option<Arc<dyn crate::auth_state::TableAuthChecker>> =
4163            if matches!(&mode_gate, OpenModeGate::SharedCore) {
4164                // Shared tables are reachable only through DatabaseHandle's
4165                // principal-explicit boundary. A core-wide checker cannot
4166                // represent multiple simultaneous principals.
4167                None
4168            } else {
4169                Some(Arc::new(crate::auth_state::DefaultTableAuthChecker::new(
4170                    auth_state.clone(),
4171                )))
4172            };
4173
4174        // Open every live table against the shared context. Mounted tables have
4175        // no private WAL (B1) — `open_in` just loads the manifest/runs and
4176        // advances the shared epoch authority to its manifest epoch, so the
4177        // final shared watermark is the max across all tables. All of a mounted
4178        // table's committed records are replayed below from the shared WAL.
4179        let mut tables: HashMap<u64, TableHandle> = HashMap::new();
4180        for entry in &cat.tables {
4181            if !matches!(entry.state, TableState::Live) {
4182                continue;
4183            }
4184            let table_root = match table_roots.remove(&entry.table_id) {
4185                Some(root) => root,
4186                None => Arc::new(
4187                    durable_root
4188                        .open_directory(Path::new(TABLES_DIR).join(entry.table_id.to_string()))?,
4189                ),
4190            };
4191            let tdir = table_root.io_path()?;
4192            let ctx = SharedCtx {
4193                root_guard: Some(table_root),
4194                epoch: Arc::clone(&epoch),
4195                page_cache: Arc::clone(&page_cache),
4196                decoded_cache: Arc::clone(&decoded_cache),
4197                snapshots: Arc::clone(&snapshots),
4198                kek: kek.clone(),
4199                commit_lock: Arc::clone(&commit_lock),
4200                shared: Some(crate::engine::SharedWalCtx {
4201                    wal: Arc::clone(&shared_wal),
4202                    group: Arc::clone(&group),
4203                    poisoned: Arc::clone(&poisoned),
4204                    txn_ids: Arc::clone(&txn_ids),
4205                    change_wake: change_wake.clone(),
4206                    lifecycle: Arc::clone(&lifecycle),
4207                    hlc: Arc::clone(&hlc),
4208                }),
4209                table_name: Some(entry.name.clone()),
4210                auth: auth_checker.clone(),
4211                read_only,
4212            };
4213            let t = Table::open_in(&tdir, ctx)?;
4214            tables.insert(entry.table_id, TableHandle::new(t));
4215        }
4216
4217        // Recover transaction writes from the shared WAL (spec §15). This is the
4218        // single durability source for mounted tables: it applies every committed
4219        // record — both single-table `Table::commit` writes and cross-table
4220        // transactions — gated by each table's `flushed_epoch` (records already
4221        // durable in a run are not re-applied).
4222        if existing {
4223            recover_shared_wal(&durable_root, &tables, &cat, &epoch, &recovery_records)?;
4224            reconcile_recovered_table_metadata(&tables, epoch.visible())?;
4225            if read_only {
4226                crate::replication::reconcile_replica_epoch_durable(
4227                    &durable_root,
4228                    epoch.visible().0,
4229                )?;
4230            }
4231            // P3.4: sweep stale `_txn/<txn_id>/` dirs left by aborted/crashed
4232            // large transactions (spec §8.5, review fix #14).
4233            sweep_pending_txn_dirs(&root, &cat);
4234        }
4235
4236        // Persist only after all semantic recovery and table mounting succeeds.
4237        catalog::write_generation(&durable_root, open_generation)?;
4238        // Storage-mode marker (spec section 5.3): fresh creates record their
4239        // mode; legacy databases (no marker file) are backfilled as Standalone
4240        // on first open. Like the open-generation write above, this is durable
4241        // open bookkeeping, so it also runs for read-only opens.
4242        match &mode_gate {
4243            OpenModeGate::Create(mode) => crate::storage_mode::write(&durable_root, mode)?,
4244            _ if existing && storage_mode.is_none() => {
4245                crate::storage_mode::write(
4246                    &durable_root,
4247                    &crate::storage_mode::StorageMode::Standalone,
4248                )?;
4249            }
4250            _ => {}
4251        }
4252        shared_wal.lock().seal_open_generation(open_generation)?;
4253        crate::replication::replication_identity_durable(&durable_root)?;
4254        let next_txn_id = (open_generation << 32) | 1;
4255        // Seed the shared txn-id allocator now that the generation is final.
4256        *txn_ids.lock() = next_txn_id;
4257        let mut lock = lock;
4258        lock.mark_open()?;
4259
4260        // Initialization is complete: recovery, WAL opening, open-generation
4261        // advancement, and table mounting all happened above, exactly once for
4262        // this core (spec §10.1, S1A-002). The core starts life `Open`.
4263        lifecycle.mark_open();
4264        let core = DatabaseCore {
4265            root,
4266            durable_root,
4267            lifecycle,
4268            registry: std::sync::OnceLock::new(),
4269            read_only,
4270            catalog: RwLock::new(cat),
4271            security_coordinator,
4272            security_catalog_disk_reads: AtomicU64::new(0),
4273            rls_cache: Mutex::new(RlsCache::default()),
4274            epoch,
4275            snapshots,
4276            memory_governor,
4277            page_cache,
4278            decoded_cache,
4279            spill_manager,
4280            resource_groups: crate::resource::ResourceGroupRegistry::with_defaults(),
4281            embedding_providers: crate::embedding::EmbeddingProviderRegistry::new(),
4282            service_principals: crate::service_principal::ServicePrincipalStore::new(),
4283            job_registry,
4284            commit_lock,
4285            kms_rotation_lock: Mutex::new(()),
4286            shared_wal,
4287            next_txn_id: txn_ids,
4288            tables: RwLock::new(tables),
4289            kek,
4290            ddl_lock: Mutex::new(()),
4291            meta_dek,
4292            conflicts: crate::txn::ConflictIndex::new(),
4293            active_txns: crate::txn::ActiveTxns::new(),
4294            lock_manager: Arc::new(crate::locks::LockManager::new()),
4295            poisoned,
4296            group,
4297            commit_log: RwLock::new(commit_log),
4298            standalone_commit_log,
4299            hlc,
4300            idempotency,
4301            commit_ts_ledger: Mutex::new(commit_ts_ledger_from_recovery(&recovery_records)),
4302            spill_threshold: std::sync::atomic::AtomicU64::new(64 * 1024 * 1024),
4303            active_spills: Arc::new(crate::retention::ActiveSpills::new()),
4304            replication_barrier: parking_lot::RwLock::new(()),
4305            replication_wal_retention_segments: AtomicUsize::new(0),
4306            backup_pins: Arc::new(Mutex::new(HashMap::new())),
4307            spill_hook: Mutex::new(None),
4308            security_commit_hook: Mutex::new(None),
4309            catalog_commit_hook: Mutex::new(None),
4310            backup_hook: Mutex::new(None),
4311            fk_lock_hook: Mutex::new(None),
4312            replication_hook: Mutex::new(None),
4313            trigger_recursive: AtomicBool::new(TriggerConfig::default().recursive_triggers),
4314            trigger_max_depth: AtomicU32::new(TriggerConfig::default().max_depth),
4315            trigger_max_loop_iterations: AtomicU32::new(
4316                TriggerConfig::default().max_loop_iterations,
4317            ),
4318            notify: {
4319                let (tx, _rx) = tokio::sync::broadcast::channel(256);
4320                tx
4321            },
4322            change_wake,
4323            _lock: Mutex::new(Some(lock)),
4324        };
4325        Ok(Self {
4326            core: Arc::new(core),
4327            principal: RwLock::new(principal),
4328            auth_state,
4329            shared: false,
4330        })
4331    }
4332
4333    /// The current reader-visible epoch.
4334    pub fn visible_epoch(&self) -> Epoch {
4335        self.epoch.visible()
4336    }
4337
4338    /// The catalog's monotonic command version (S1F-001).
4339    pub fn catalog_version(&self) -> u64 {
4340        self.catalog.read().catalog_version
4341    }
4342
4343    /// The durable storage mode recorded for this database (spec section 5.3).
4344    /// `None` only while a legacy pre-marker database is mid-open (the marker
4345    /// is backfilled before open completes).
4346    pub fn storage_mode(&self) -> Result<Option<crate::storage_mode::StorageMode>> {
4347        Ok(crate::storage_mode::read(&self.durable_root)?)
4348    }
4349
4350    /// The core's node-level memory governor (S1E-003). Exactly one per core;
4351    /// the page caches reserve under it and register as reclaimable.
4352    pub fn memory_governor(&self) -> &crate::memory::MemoryGovernor {
4353        &self.memory_governor
4354    }
4355
4356    /// Workload resource-group registry (S1E-002). Seeded with defaults for
4357    /// every [`crate::resource::WorkloadClass`] at open.
4358    pub fn resource_groups(&self) -> &crate::resource::ResourceGroupRegistry {
4359        &self.resource_groups
4360    }
4361
4362    /// Process-local embedding provider registry. Core storage never hard-codes
4363    /// an external vendor; register local/remote providers here when generation
4364    /// is desired. Application-supplied vectors need no registration.
4365    pub fn embedding_providers(&self) -> &crate::embedding::EmbeddingProviderRegistry {
4366        &self.embedding_providers
4367    }
4368
4369    /// Remove a provider only when no live schema references it.
4370    pub fn unregister_embedding_provider(&self, provider_id: &str) -> Result<bool> {
4371        let references = self
4372            .catalog
4373            .read()
4374            .tables
4375            .iter()
4376            .flat_map(|table| &table.schema.columns)
4377            .filter(|column| {
4378                column
4379                    .embedding_source
4380                    .as_ref()
4381                    .and_then(crate::embedding::EmbeddingSource::provider_id)
4382                    == Some(provider_id)
4383            })
4384            .count();
4385        self.embedding_providers
4386            .unregister_unreferenced(provider_id, references)
4387            .map_err(embedding_error)
4388    }
4389
4390    fn materialize_generated_embeddings(
4391        &self,
4392        staging: &mut [(u64, crate::txn::Staged)],
4393        control: Option<&crate::ExecutionControl>,
4394    ) -> Result<()> {
4395        let schemas = {
4396            let catalog = self.catalog.read();
4397            catalog
4398                .tables
4399                .iter()
4400                .map(|table| (table.table_id, table.schema.clone()))
4401                .collect::<HashMap<_, _>>()
4402        };
4403        let fallback_control = crate::ExecutionControl::new(None);
4404        let control = control.unwrap_or(&fallback_control);
4405        for (table_id, staged) in &mut *staging {
4406            let (cells, mut update_changed_columns) = match staged {
4407                crate::txn::Staged::Put(cells) => (cells, None),
4408                crate::txn::Staged::Update {
4409                    new_row,
4410                    changed_columns,
4411                    ..
4412                } => (new_row, Some(changed_columns)),
4413                crate::txn::Staged::Delete(_) | crate::txn::Staged::Truncate => continue,
4414            };
4415            let schema = schemas.get(table_id).ok_or_else(|| {
4416                MongrelError::Schema(format!("table id {table_id} disappeared during embedding"))
4417            })?;
4418            for target in &schema.columns {
4419                let Some(crate::embedding::EmbeddingSource::GeneratedColumnSpec { spec }) =
4420                    target.embedding_source.as_ref()
4421                else {
4422                    continue;
4423                };
4424                let text = render_embedding_input(schema, spec, cells)?;
4425                let reservation_bytes = text
4426                    .len()
4427                    .saturating_add((spec.dimension as usize).saturating_mul(4));
4428                let _reservation = self
4429                    .memory_governor
4430                    .try_reserve(
4431                        reservation_bytes as u64,
4432                        crate::memory::MemoryClass::AiCandidates,
4433                    )
4434                    .map_err(|error| MongrelError::ResourceLimitExceeded {
4435                        resource: "embedding memory",
4436                        requested: reservation_bytes,
4437                        limit: match error {
4438                            crate::memory::MemoryError::Exhausted { available, .. } => {
4439                                available as usize
4440                            }
4441                            _ => 0,
4442                        },
4443                    })?;
4444                let source =
4445                    crate::embedding::EmbeddingSource::GeneratedColumnSpec { spec: spec.clone() };
4446                let (registry_generation, provider) = self
4447                    .embedding_providers
4448                    .resolve_with_generation(&source)
4449                    .map_err(embedding_error)?;
4450                let semantic_identity = provider.semantic_identity();
4451                self.ensure_ann_semantic_identity_compatible(
4452                    *table_id,
4453                    target.id,
4454                    &semantic_identity,
4455                )?;
4456                let vectors = self
4457                    .embedding_providers
4458                    .embed_controlled(
4459                        &source,
4460                        &[text.as_str()],
4461                        spec.dimension,
4462                        control,
4463                        "generated-column-write",
4464                        crate::embedding::EmbeddingLimits::default(),
4465                    )
4466                    .map_err(embedding_error)?;
4467                cells.retain(|(column, _)| *column != target.id);
4468                cells.push((
4469                    target.id,
4470                    crate::memtable::Value::GeneratedEmbedding(Box::new(
4471                        crate::embedding::GeneratedEmbeddingValue {
4472                            vector: vectors.into_iter().next().ok_or_else(|| {
4473                                MongrelError::Other("embedding provider returned no vector".into())
4474                            })?,
4475                            metadata: crate::embedding::GeneratedEmbeddingMetadata {
4476                                provider_id: provider.provider_id().to_owned(),
4477                                model_id: provider.model_id().to_owned(),
4478                                model_version: provider.model_version().to_owned(),
4479                                preprocessing_version: provider.preprocessing_version().to_owned(),
4480                                source_fingerprint: Sha256::digest(text.as_bytes()).into(),
4481                                status: crate::embedding::EmbeddingGenerationStatus::Ready,
4482                                last_error_category: None,
4483                                attempt_count: 1,
4484                                semantic_identity,
4485                                provider_registry_generation: registry_generation,
4486                            },
4487                        },
4488                    )),
4489                ));
4490                if let Some(changed_columns) = update_changed_columns.as_deref_mut() {
4491                    changed_columns.push(target.id);
4492                }
4493            }
4494            if let Some(changed_columns) = update_changed_columns {
4495                changed_columns.sort_unstable();
4496                changed_columns.dedup();
4497            }
4498        }
4499        self.validate_staged_generated_embedding_identities(staging)?;
4500        Ok(())
4501    }
4502
4503    fn ensure_ann_semantic_identity_compatible(
4504        &self,
4505        table_id: u64,
4506        column_id: u16,
4507        identity: &crate::embedding::EmbeddingProviderRef,
4508    ) -> Result<()> {
4509        let tables = self.tables.read();
4510        let Some(handle) = tables.get(&table_id) else {
4511            return Ok(());
4512        };
4513        let table = handle.lock();
4514        let Some(ann) = table.ann_index(column_id) else {
4515            return Ok(());
4516        };
4517        if let Some(existing) = ann.semantic_identity() {
4518            if existing != identity {
4519                return Err(embedding_error(
4520                    crate::embedding::EmbeddingError::AnnSemanticIdentityMismatch {
4521                        column_id,
4522                        expected: existing.fingerprint_sha256(),
4523                        got: identity.fingerprint_sha256(),
4524                    },
4525                ));
4526            }
4527        }
4528        Ok(())
4529    }
4530
4531    fn validate_staged_generated_embedding_identities(
4532        &self,
4533        staging: &[(u64, crate::txn::Staged)],
4534    ) -> Result<()> {
4535        for (table_id, staged) in staging {
4536            let cells = match staged {
4537                crate::txn::Staged::Put(cells) => cells.as_slice(),
4538                crate::txn::Staged::Update { new_row, .. } => new_row.as_slice(),
4539                crate::txn::Staged::Delete(_) | crate::txn::Staged::Truncate => continue,
4540            };
4541            for (column_id, value) in cells {
4542                let Some(meta) = value.generated_embedding_metadata() else {
4543                    continue;
4544                };
4545                self.ensure_ann_semantic_identity_compatible(
4546                    *table_id,
4547                    *column_id,
4548                    &meta.semantic_identity,
4549                )?;
4550            }
4551        }
4552        Ok(())
4553    }
4554
4555    /// Acquire exclusive row locks for `SELECT ... FOR UPDATE` (spec §10.2).
4556    /// Locks are held until the transaction ends (`release_txn_locks` /
4557    /// commit / abort). Requires an open transaction id from the SQL session.
4558    pub fn lock_rows_for_update(
4559        &self,
4560        txn_id: u64,
4561        table_id: u64,
4562        row_ids: &[crate::rowid::RowId],
4563        control: Option<&crate::ExecutionControl>,
4564    ) -> Result<()> {
4565        for &row_id in row_ids {
4566            self.acquire_txn_lock(
4567                txn_id,
4568                crate::locks::LockKey::row(table_id, row_id),
4569                crate::locks::LockMode::Exclusive,
4570                control,
4571            )?;
4572        }
4573        Ok(())
4574    }
4575
4576    /// The core's node-level spill manager (S1E-004), rooted at
4577    /// `<db-root>/temp/spill`. Query engines open per-query spill sessions
4578    /// from it.
4579    pub fn spill_manager(&self) -> &crate::spill::SpillManager {
4580        &self.spill_manager
4581    }
4582
4583    /// The core's durable job registry (S1F-002, sibling `JOBS` file).
4584    pub fn job_registry(&self) -> &Arc<crate::jobs::JobRegistry> {
4585        &self.job_registry
4586    }
4587
4588    /// S1C-004 diagnostics: every mounted table's active version-retention
4589    /// pin sources, aggregated from [`crate::engine::Table::version_pins_report`].
4590    /// A version may be reclaimed only when it is older than the oldest pin of
4591    /// every source; this report exposes each of them per table.
4592    pub fn version_pins_report(&self) -> Vec<TablePinsReport> {
4593        let names: HashMap<u64, String> = self
4594            .catalog
4595            .read()
4596            .tables
4597            .iter()
4598            .map(|entry| (entry.table_id, entry.name.clone()))
4599            .collect();
4600        let handles: Vec<_> = self
4601            .tables
4602            .read()
4603            .iter()
4604            .map(|(table_id, handle)| (*table_id, handle.clone()))
4605            .collect();
4606        handles
4607            .into_iter()
4608            .map(|(table_id, handle)| {
4609                let pins = handle.lock().version_pins_report();
4610                TablePinsReport {
4611                    table_id,
4612                    table: names.get(&table_id).cloned().unwrap_or_default(),
4613                    pins,
4614                }
4615            })
4616            .collect()
4617    }
4618
4619    /// P0.5-T6: HLC GC floor with named pin sources, projected through the
4620    /// durable commit-ts ledger when an epoch pin has a stamp.
4621    ///
4622    /// Sources without an active pin or without a durable HLC projection
4623    /// report [`HlcTimestamp::ZERO`](mongreldb_types::hlc::HlcTimestamp::ZERO).
4624    /// Physical reclamation still gates on the epoch floor; this is the HLC
4625    /// diagnostic / cutover surface.
4626    pub fn hlc_gc_floor(&self) -> crate::epoch::GcFloor {
4627        let mut combined = crate::epoch::GcFloor::ZERO;
4628        // Database-level transaction / history pins (shared SnapshotRegistry).
4629        if let Some(epoch) = self.snapshots.min_pinned() {
4630            if let Some(ts) = self.commit_ts_for_epoch(epoch) {
4631                combined.transaction_snapshot = ts;
4632            }
4633        }
4634        if let Some(epoch) = self.snapshots.history_floor(self.epoch.visible()) {
4635            if let Some(ts) = self.commit_ts_for_epoch(epoch) {
4636                combined.history_retention = ts;
4637            }
4638        }
4639        // Merge per-table pin registry projections (min of non-zero).
4640        let handles: Vec<_> = self.tables.read().values().cloned().collect();
4641        for handle in handles {
4642            let table_floor = handle
4643                .lock()
4644                .hlc_gc_floor(|epoch| self.commit_ts_for_epoch(epoch));
4645            for (label, ts) in table_floor.sources() {
4646                if ts == mongreldb_types::hlc::HlcTimestamp::ZERO {
4647                    continue;
4648                }
4649                let slot = match label {
4650                    "transaction_snapshot" => &mut combined.transaction_snapshot,
4651                    "history_retention" => &mut combined.history_retention,
4652                    "backup_pitr" => &mut combined.backup_pitr,
4653                    "replication" => &mut combined.replication,
4654                    "read_generation" => &mut combined.read_generation,
4655                    "online_index_build" => &mut combined.online_index_build,
4656                    _ => continue,
4657                };
4658                if *slot == mongreldb_types::hlc::HlcTimestamp::ZERO || ts < *slot {
4659                    *slot = ts;
4660                }
4661            }
4662        }
4663        combined
4664    }
4665
4666    /// The core's key/predicate lock manager (S1B-003).
4667    pub fn lock_manager(&self) -> &Arc<crate::locks::LockManager> {
4668        &self.lock_manager
4669    }
4670
4671    /// S1B-004 step 12: release every lock `txn_id` holds (commit, abort, and
4672    /// rollback all funnel here). Idempotent — releasing a transaction with
4673    /// no holds is a no-op.
4674    /// Release every lock held by `txn_id` (COMMIT/ROLLBACK/FOR UPDATE end).
4675    pub fn release_txn_locks(&self, txn_id: u64) {
4676        self.lock_manager.release_all(txn_id);
4677    }
4678
4679    /// Build a lock request for `txn_id`, inheriting the commit's
4680    /// cancellation control (or a plain one when the commit is uncontrolled).
4681    fn txn_lock_request(
4682        txn_id: u64,
4683        mode: crate::locks::LockMode,
4684        control: Option<&crate::ExecutionControl>,
4685    ) -> crate::locks::LockRequest {
4686        let control = control
4687            .cloned()
4688            .unwrap_or_else(|| crate::ExecutionControl::new(None));
4689        crate::locks::LockRequest::new(txn_id, mode, control)
4690    }
4691
4692    /// Acquire one lock for `txn_id`, bridging the typed [`crate::locks::LockError`]
4693    /// onto the engine error (deadlock victims surface as
4694    /// [`MongrelError::Deadlock`] with [`mongreldb_types::error::ErrorCategory::Deadlock`]).
4695    pub(crate) fn acquire_txn_lock(
4696        &self,
4697        txn_id: u64,
4698        key: crate::locks::LockKey,
4699        mode: crate::locks::LockMode,
4700        control: Option<&crate::ExecutionControl>,
4701    ) -> Result<()> {
4702        self.lock_manager
4703            .acquire(key, Self::txn_lock_request(txn_id, mode, control))
4704            .map_err(MongrelError::from)
4705    }
4706
4707    /// S1B-003: acquire the schema barrier Exclusive for one DDL operation.
4708    /// The hold releases when the returned guard drops (end of the DDL entry
4709    /// point). DML transactions hold the barrier Shared for their whole
4710    /// commit, so schema changes exclude concurrent DML and one another.
4711    fn acquire_schema_barrier_exclusive(&self) -> Result<TxnLockGuard<'_>> {
4712        let txn_id = self.alloc_txn_id()?;
4713        self.acquire_txn_lock(
4714            txn_id,
4715            crate::locks::LockKey::schema_barrier(),
4716            crate::locks::LockMode::Exclusive,
4717            None,
4718        )?;
4719        Ok(TxnLockGuard {
4720            locks: &self.lock_manager,
4721            txn_id,
4722        })
4723    }
4724
4725    /// The commit-log authority for this database (spec §9.4, FND-004). Every
4726    /// commit path proposes through it and visibility publication is gated on
4727    /// its receipts; Stage 2 swaps this standalone adapter for the replicated
4728    /// implementation behind the same `CommitLog` trait.
4729    pub fn commit_log(&self) -> Arc<dyn mongreldb_log::CommitLog> {
4730        Arc::clone(&self.commit_log.read())
4731    }
4732
4733    /// Bind a cluster replica core to its owning consensus commit authority.
4734    ///
4735    /// Standalone roots reject substitution. Cluster replica user mutations
4736    /// remain read-only; this binding exposes the same authoritative log to
4737    /// runtime diagnostics and future proposal surfaces while committed apply
4738    /// stays the only engine mutation path.
4739    pub fn bind_cluster_commit_log(
4740        &self,
4741        commit_log: Arc<dyn mongreldb_log::CommitLog>,
4742    ) -> Result<()> {
4743        match self.storage_mode()? {
4744            Some(crate::storage_mode::StorageMode::ClusterReplica { .. }) => {
4745                *self.commit_log.write() = commit_log;
4746                Ok(())
4747            }
4748            mode => Err(MongrelError::InvalidArgument(format!(
4749                "commit-log substitution requires ClusterReplica storage, got {mode:?}"
4750            ))),
4751        }
4752    }
4753
4754    /// Break the cluster commit-log binding during replica-runtime teardown.
4755    ///
4756    /// The replacement standalone adapter is retained only as an inert
4757    /// lifecycle anchor. Cluster replica writes remain rejected, and a
4758    /// restarted runtime must bind its new Raft authority before serving.
4759    pub fn detach_cluster_commit_log(&self) -> Result<()> {
4760        match self.storage_mode()? {
4761            Some(crate::storage_mode::StorageMode::ClusterReplica { .. }) => {
4762                let standalone: Arc<dyn mongreldb_log::CommitLog> =
4763                    self.standalone_commit_log.clone();
4764                *self.commit_log.write() = standalone;
4765                Ok(())
4766            }
4767            mode => Err(MongrelError::InvalidArgument(format!(
4768                "commit-log detachment requires ClusterReplica storage, got {mode:?}"
4769            ))),
4770        }
4771    }
4772
4773    /// The node's HLC timestamp authority (spec §8.2, ADR-0003). Transaction
4774    /// `begin` captures read timestamps here; the commit sequencer allocates
4775    /// commit timestamps from the same clock so commit ts > every participant
4776    /// read/write timestamp of the transaction.
4777    /// Shared HLC clock for this core (P0.5 / cluster apply stamping).
4778    pub fn hlc(&self) -> &mongreldb_types::hlc::HlcClock {
4779        self.hlc_clock()
4780    }
4781
4782    pub(crate) fn hlc_clock(&self) -> &mongreldb_types::hlc::HlcClock {
4783        &self.hlc
4784    }
4785
4786    /// Clone the in-memory catalog (for diagnostics / tests).
4787    pub fn catalog_snapshot(&self) -> Catalog {
4788        self.catalog.read().clone()
4789    }
4790
4791    /// Read SQLite-compatible application metadata persisted in the catalog.
4792    pub fn sql_pragma_i64(&self, key: &str) -> Result<Option<i64>> {
4793        let catalog = self.catalog.read();
4794        match key {
4795            "user_version" => Ok(catalog.user_version),
4796            "application_id" => Ok(catalog.application_id),
4797            _ => Err(MongrelError::InvalidArgument(format!(
4798                "unsupported persistent SQL pragma {key:?}"
4799            ))),
4800        }
4801    }
4802
4803    /// Persist SQLite-compatible application metadata and return its exact
4804    /// publication epoch. An unchanged value performs no durable write.
4805    pub fn set_sql_pragma_i64_with_epoch(&self, key: &str, value: i64) -> Result<Option<Epoch>> {
4806        self.set_sql_pragma_i64_with_epoch_inner(key, value, None)
4807    }
4808
4809    pub fn set_sql_pragma_i64_with_epoch_controlled<F>(
4810        &self,
4811        key: &str,
4812        value: i64,
4813        mut before_commit: F,
4814    ) -> Result<Option<Epoch>>
4815    where
4816        F: FnMut() -> Result<()>,
4817    {
4818        self.set_sql_pragma_i64_with_epoch_inner(key, value, Some(&mut before_commit))
4819    }
4820
4821    fn set_sql_pragma_i64_with_epoch_inner(
4822        &self,
4823        key: &str,
4824        value: i64,
4825        before_commit: Option<&mut dyn FnMut() -> Result<()>>,
4826    ) -> Result<Option<Epoch>> {
4827        use crate::wal::DdlOp;
4828
4829        self.require(&crate::auth::Permission::Ddl)?;
4830        if self.read_only {
4831            return Err(MongrelError::ReadOnlyReplica);
4832        }
4833        if self.poisoned.load(Ordering::Relaxed) {
4834            return Err(MongrelError::Other(
4835                "database poisoned by fsync error".into(),
4836            ));
4837        }
4838        let _ddl = self.ddl_lock.lock();
4839        let _security_write = self.security_write()?;
4840        self.require(&crate::auth::Permission::Ddl)?;
4841        let mut next_catalog = self.catalog.read().clone();
4842        let target = match key {
4843            "user_version" => &mut next_catalog.user_version,
4844            "application_id" => &mut next_catalog.application_id,
4845            _ => {
4846                return Err(MongrelError::InvalidArgument(format!(
4847                    "unsupported persistent SQL pragma {key:?}"
4848                )))
4849            }
4850        };
4851        if *target == Some(value) {
4852            return Ok(None);
4853        }
4854        *target = Some(value);
4855
4856        let _commit = self.commit_lock.lock();
4857        let epoch = self.epoch.bump_assigned();
4858        let mut epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
4859        let txn_id = self.alloc_txn_id()?;
4860        next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
4861        let commit_seq = {
4862            let mut wal = self.shared_wal.lock();
4863            if let Some(before_commit) = before_commit {
4864                before_commit()?;
4865            }
4866            let append: Result<u64> = (|| {
4867                wal.append(
4868                    txn_id,
4869                    WAL_TABLE_ID,
4870                    crate::wal::Op::Ddl(DdlOp::SetSqlPragma {
4871                        key: key.to_string(),
4872                        value,
4873                    }),
4874                )?;
4875                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
4876                wal.append_commit(txn_id, epoch, &[])
4877            })();
4878            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
4879        };
4880        let receipt = self.await_durable_commit(txn_id, commit_seq, epoch)?;
4881        let checkpoint = self.checkpoint_catalog_after_durable(next_catalog);
4882        self.finish_durable_publish(epoch, &mut epoch_guard, &receipt, checkpoint)?;
4883        Ok(Some(epoch))
4884    }
4885
4886    pub fn materialized_view(&self, name: &str) -> Option<crate::catalog::MaterializedViewEntry> {
4887        self.catalog
4888            .read()
4889            .materialized_views
4890            .iter()
4891            .find(|definition| definition.name == name)
4892            .cloned()
4893    }
4894
4895    pub fn materialized_views(&self) -> Vec<crate::catalog::MaterializedViewEntry> {
4896        self.catalog.read().materialized_views.clone()
4897    }
4898
4899    pub fn security_catalog(&self) -> crate::security::SecurityCatalog {
4900        self.catalog.read().security.clone()
4901    }
4902
4903    pub fn security_active_for(&self, table: &str) -> bool {
4904        self.catalog.read().security.table_has_security(table)
4905    }
4906
4907    fn refresh_security_catalog_if_stale(&self, expected_version: u64) -> Result<()> {
4908        if self.catalog.read().security_version == expected_version {
4909            return Ok(());
4910        }
4911        self.security_catalog_disk_reads
4912            .fetch_add(1, Ordering::Relaxed);
4913        let fresh = catalog::read_durable(&self.durable_root, self.meta_dek.as_ref())?
4914            .ok_or_else(|| MongrelError::NotFound("catalog vanished during write".into()))?;
4915        let principal = self.principal.read().clone();
4916        let principal = if fresh.require_auth
4917            && principal
4918                .as_ref()
4919                .is_some_and(|principal| principal.user_id != 0)
4920        {
4921            principal
4922                .as_ref()
4923                .and_then(|principal| Self::resolve_bound_principal_from_catalog(&fresh, principal))
4924        } else {
4925            principal
4926        };
4927        self.auth_state.set_require_auth(fresh.require_auth);
4928        *self.catalog.write() = fresh;
4929        *self.principal.write() = principal.clone();
4930        self.auth_state.set_principal(principal);
4931        Ok(())
4932    }
4933
4934    fn security_write(&self) -> Result<parking_lot::RwLockWriteGuard<'_, ()>> {
4935        let guard = self.security_coordinator.gate.write();
4936        let version = self.security_coordinator.version.load(Ordering::Acquire);
4937        self.refresh_security_catalog_if_stale(version)?;
4938        Ok(guard)
4939    }
4940
4941    /// Commit an exact catalog image through the shared WAL, then checkpoint it.
4942    /// The WAL image is the authoritative PITR and replication delta; CATALOG is
4943    /// only its restart checkpoint.
4944    /// S1A-004: admit one operation against the core (rejects once the core
4945    /// leaves [`crate::core::LifecycleState::Open`] — draining, closing,
4946    /// closed, or poisoned). The returned guard holds the operation slot
4947    /// until dropped, so `shutdown()` drains exactly the covered operations.
4948    ///
4949    /// Covered set (the mutating/durable paths; the legacy fsync-poison error
4950    /// still wins where a body already checks `self.poisoned`, because the
4951    /// guard is taken after that check):
4952    ///
4953    /// - the cross-table commit funnel
4954    ///   (`commit_transaction_with_external_states_inner`), covering every
4955    ///   user and internal transaction commit;
4956    /// - the catalog publish funnel (`publish_catalog_candidate_with_prelude`),
4957    ///   covering the user/role/grant, trigger, and procedure families;
4958    /// - the inline-WAL DDL bodies: table create/drop/rename/alter, CTAS
4959    ///   rebuilding publish, security-catalog and materialized-view
4960    ///   replacement, external-table create/drop/reset;
4961    /// - storage maintenance: `gc`, `checkpoint`, `compact`, `hot_backup`;
4962    /// - replication bootstrap, batch extraction, and follow-apply.
4963    ///
4964    /// Read-only entry points (snapshots, queries, stats) and the infallible
4965    /// `begin*` constructors are intentionally not covered: reads never block
4966    /// shutdown, and a transaction's durable act is its commit.
4967    pub(crate) fn admit_operation(&self) -> Result<crate::core::OperationGuard> {
4968        self.core.operation_guard()
4969    }
4970
4971    /// S1F-001: wrap `command` in its versioned record and apply it to the
4972    /// catalog candidate (validate → mutate → bump `catalog_version` → append
4973    /// the bounded command history). Security-catalog changes advance the
4974    /// security version inside `CatalogDelta::apply_to`, exactly where the
4975    /// legacy mutation bodies called `advance_security_version` themselves.
4976    fn apply_catalog_command_to(
4977        &self,
4978        next_catalog: &mut Catalog,
4979        command: crate::catalog_cmds::CatalogCommand,
4980    ) -> Result<crate::catalog_cmds::CatalogDelta> {
4981        let record = crate::catalog_cmds::CatalogCommandRecord::next(next_catalog, command);
4982        next_catalog.apply_command(&record)
4983    }
4984
4985    /// Stage 2E (spec section 11.5): apply one committed replicated
4986    /// transaction's staged records through the **same** logic the WAL
4987    /// recovery path uses ([`recover_shared_wal`]).
4988    ///
4989    /// The payload is a complete record sequence of one committed transaction
4990    /// (data ops, `Op::CommitTimestamp`, and exactly one trailing
4991    /// `Op::TxnCommit`), carrying the leader-assigned commit epoch so every
4992    /// replica applies byte-identical records deterministically.
4993    ///
4994    /// Application is durable before it returns: the records are appended
4995    /// verbatim to the core's shared WAL and group-synced, so the raft state
4996    /// machine's post-apply checkpoint never acknowledges rows a crash would
4997    /// lose. Application is idempotent: a payload whose commit epoch is at or
4998    /// below the core's visible watermark is a replay (the state machine
4999    /// dispatches sink-first, checkpoints second — a crash in that window
5000    /// redelivers) and is skipped without side effects. Returns `Ok(true)`
5001    /// when the payload was applied, `Ok(false)` for a recognized replay.
5002    pub fn apply_replicated_records(&self, records: &[crate::wal::Record]) -> Result<bool> {
5003        use crate::wal::Op;
5004        use std::sync::atomic::Ordering;
5005
5006        let _operation = self.admit_operation()?;
5007        if self.poisoned.load(Ordering::Relaxed) {
5008            return Err(MongrelError::Other(
5009                "database poisoned by fsync error".into(),
5010            ));
5011        }
5012        // Structural validation (fail closed): one transaction, exactly one
5013        // commit marker, at the tail.
5014        let txn_id = records.first().map(|record| record.txn_id).ok_or_else(|| {
5015            MongrelError::InvalidArgument("replicated transaction payload is empty".into())
5016        })?;
5017        if records.iter().any(|record| record.txn_id != txn_id) {
5018            return Err(MongrelError::InvalidArgument(
5019                "replicated transaction payload mixes transaction ids".into(),
5020            ));
5021        }
5022        let commits = records
5023            .iter()
5024            .filter(|record| matches!(record.op, Op::TxnCommit { .. }))
5025            .count();
5026        if commits != 1 || !matches!(records.last().map(|r| &r.op), Some(Op::TxnCommit { .. })) {
5027            return Err(MongrelError::InvalidArgument(
5028                "replicated transaction payload must end in exactly one commit marker".into(),
5029            ));
5030        }
5031        let commit_epoch = match records.last().map(|r| &r.op) {
5032            Some(Op::TxnCommit { epoch, .. }) => *epoch,
5033            _ => unreachable!("validated above"),
5034        };
5035        // Fail closed on spilled-run linking: an `added_runs` commit links
5036        // run files that exist only on the leader. Leaders never emit such a
5037        // payload — the Stage 2C envelope builder passes every commit through
5038        // [`translate_records_for_replication`], which stages the spilled rows
5039        // as logical `Op::Put` records and strips the run links (spec section
5040        // 11.3 step 3). This check is the defense-in-depth gate behind that
5041        // contract: a payload carrying run references is un-appliable here
5042        // and is rejected deterministically rather than diverging.
5043        if let Some(Op::TxnCommit { added_runs, .. }) = records.last().map(|r| &r.op) {
5044            if !added_runs.is_empty() {
5045                return Err(MongrelError::InvalidArgument(
5046                    "replicated spilled-run commits are not appliable: the leader must translate \
5047                     the commit through translate_records_for_replication before proposal"
5048                        .into(),
5049                ));
5050            }
5051        }
5052        // Replay guard: the leader assigns one monotonically increasing epoch
5053        // per committed transaction in log order, so anything at or below the
5054        // core's watermark is already applied.
5055        if commit_epoch <= self.epoch.visible().0 {
5056            return Ok(false);
5057        }
5058        // Stage durable first: verbatim WAL append + fsync. The raft log is
5059        // the commit authority; the local WAL is this replica's durable
5060        // staging of already-committed commands (restart recovery replays it
5061        // through the identical path, gated by flushed epochs).
5062        {
5063            let mut wal = self.shared_wal.lock();
5064            for record in records {
5065                wal.append(record.txn_id, 0, record.op.clone())?;
5066            }
5067            if let Err(error) = wal.group_sync() {
5068                self.poisoned.store(true, Ordering::Relaxed);
5069                return Err(error);
5070            }
5071        }
5072        // S1C-004: pin the pre-apply visible epoch for the duration of apply so
5073        // concurrent GC cannot reclaim versions a catch-up reader still needs.
5074        let replication_pins: Vec<crate::retention::PinGuard> = {
5075            let tables = self.tables.read();
5076            let floor = Epoch(self.epoch.visible().0);
5077            tables
5078                .values()
5079                .map(|handle| {
5080                    let t = handle.lock();
5081                    Arc::clone(t.pin_registry())
5082                        .pin(crate::retention::PinSource::Replication, floor)
5083                })
5084                .collect()
5085        };
5086        let tables = self.tables.read().clone();
5087        let catalog = self.catalog.read().clone();
5088        recover_shared_wal(&self.durable_root, &tables, &catalog, &self.epoch, records)?;
5089        // Replica-side commit-ts ledger: record the leader-assigned commit
5090        // epoch's physical time from any CommitTimestamp op, else stamp from
5091        // the local HLC so `commit_ts_for_epoch` serves PITR/read-your-writes
5092        // on replicas (Stage 2 residual).
5093        if let Some(Op::TxnCommit { epoch, .. }) = records.last().map(|r| &r.op) {
5094            let commit_ts = records
5095                .iter()
5096                .rev()
5097                .find_map(|record| match &record.op {
5098                    Op::CommitTimestamp { unix_nanos } => {
5099                        Some(mongreldb_types::hlc::HlcTimestamp {
5100                            physical_micros: unix_nanos / 1_000,
5101                            logical: 0,
5102                            node_tiebreaker: 0,
5103                        })
5104                    }
5105                    _ => None,
5106                })
5107                .unwrap_or_else(|| {
5108                    self.hlc
5109                        .now()
5110                        .unwrap_or(mongreldb_types::hlc::HlcTimestamp {
5111                            physical_micros: 0,
5112                            logical: 0,
5113                            node_tiebreaker: 0,
5114                        })
5115                });
5116            self.record_commit_ts(Epoch(*epoch), commit_ts);
5117        }
5118        drop(replication_pins);
5119        Ok(true)
5120    }
5121
5122    /// Stage 3H engine binding (spec section 12.8): validate staged
5123    /// write-intent payloads at prepare time. A participant that durably
5124    /// prepares these payloads must be able to apply them at resolution, so
5125    /// every check the resolution apply performs runs here first: each
5126    /// payload decodes as a [`StagedTxnWrite`], its table is mounted, and
5127    /// every staged row satisfies the same persisted-row validation the WAL
5128    /// recovery path applies. A malformed payload is rejected deterministically
5129    /// at prepare — never after a decision commits, where a rejection would
5130    /// wedge the replicated apply stream.
5131    pub fn validate_staged_txn_writes(&self, staged: &[Vec<u8>]) -> Result<()> {
5132        let tables = self.tables.read();
5133        for payload in staged {
5134            match StagedTxnWrite::decode(payload)? {
5135                StagedTxnWrite::Put { table_id, rows } => {
5136                    let handle = tables.get(&table_id).ok_or_else(|| {
5137                        MongrelError::InvalidArgument(format!(
5138                            "staged write targets unmounted table {table_id}"
5139                        ))
5140                    })?;
5141                    let rows: Vec<crate::memtable::Row> =
5142                        bincode::deserialize(&rows).map_err(|error| {
5143                            MongrelError::InvalidArgument(format!(
5144                                "staged put payload for table {table_id} cannot decode: {error}"
5145                            ))
5146                        })?;
5147                    let schema = handle.lock().schema().clone();
5148                    for row in &rows {
5149                        validate_recovered_row(&schema, row).map_err(|error| {
5150                            MongrelError::InvalidArgument(format!(
5151                                "staged row for table {table_id} is not appliable: {error}"
5152                            ))
5153                        })?;
5154                    }
5155                }
5156                StagedTxnWrite::Delete { table_id, row_ids } => {
5157                    if !tables.contains_key(&table_id) {
5158                        return Err(MongrelError::InvalidArgument(format!(
5159                            "staged delete targets unmounted table {table_id}"
5160                        )));
5161                    }
5162                    if row_ids.contains(&u64::MAX) {
5163                        return Err(MongrelError::InvalidArgument(format!(
5164                            "staged delete for table {table_id} names an exhausted row id"
5165                        )));
5166                    }
5167                }
5168            }
5169        }
5170        Ok(())
5171    }
5172
5173    /// Stage 3H engine binding (spec section 12.8): apply one committed
5174    /// resolution's staged writes through the replicated apply path
5175    /// ([`Database::apply_replicated_records`]) at the decision's commit
5176    /// timestamp. `txn_tag` is the caller's deterministic per-transaction
5177    /// tag (e.g. a hash of the distributed transaction id); it must be
5178    /// identical on every replica. The synthetic WAL transaction id is
5179    /// derived from it inside the current open-generation namespace with the
5180    /// allocator-avoidance bit set, so resolution records pass the
5181    /// open-generation durability check on reopen and never alias
5182    /// leader-allocated transaction ids.
5183    ///
5184    /// The commit epoch stamped into the synthetic commit marker is one past
5185    /// the core's visible watermark: the replicated apply stream is
5186    /// deterministic, so every replica computes the identical epoch at the
5187    /// identical apply point, and the core's own restart recovery replays the
5188    /// stamped sequence verbatim. Rows are restamped at that epoch by the
5189    /// recovery logic, becoming visible exactly as an ordinary committed
5190    /// transaction's rows.
5191    ///
5192    /// Alias: [`Self::apply_committed_transaction`] — same semantics under the
5193    /// audit's apply-API name (P0.5-T4).
5194    pub fn apply_staged_txn_writes(
5195        &self,
5196        txn_tag: u64,
5197        staged: &[Vec<u8>],
5198        commit_ts: mongreldb_types::hlc::HlcTimestamp,
5199    ) -> Result<bool> {
5200        use crate::wal::Op;
5201
5202        // Decode every payload before any mutation (fail closed).
5203        let mut writes = Vec::with_capacity(staged.len());
5204        for payload in staged {
5205            writes.push(StagedTxnWrite::decode(payload)?);
5206        }
5207        // Generation-namespace the synthetic transaction id: the high 32 bits
5208        // are the current open generation (the reopen path rejects retained
5209        // records from a generation beyond the durable WAL head); the low 32
5210        // bits carry the caller's tag with the top bit set, outside the
5211        // ascending leader-allocated counter space.
5212        let generation = *self.next_txn_id.lock() >> 32;
5213        let txn_id = (generation << 32) | (txn_tag & 0x7FFF_FFFF) | 0x8000_0000;
5214        let mut records = Vec::with_capacity(writes.len() + 2);
5215        for write in writes {
5216            let op = match write {
5217                StagedTxnWrite::Put { table_id, rows } => {
5218                    // `Row::commit_ts` is `#[serde(skip)]` (0.63.1 WAL bincode
5219                    // layout), so the shared decision HLC cannot ride the Put
5220                    // payload. It travels in the trailing `Op::CommitTimestamp`
5221                    // record (physical micros) and is restamped onto every row
5222                    // version by the apply/recovery path, keeping live apply
5223                    // and WAL recovery on the identical stamp.
5224                    let decoded: Vec<crate::memtable::Row> =
5225                        bincode::deserialize(&rows).map_err(|e| {
5226                            MongrelError::Other(format!(
5227                                "staged txn put rows could not be decoded: {e}"
5228                            ))
5229                        })?;
5230                    let stamped = bincode::serialize(&decoded).map_err(|e| {
5231                        MongrelError::Other(format!("staged txn put rows serialize: {e}"))
5232                    })?;
5233                    Op::Put {
5234                        table_id,
5235                        rows: stamped,
5236                    }
5237                }
5238                StagedTxnWrite::Delete { table_id, row_ids } => Op::Delete {
5239                    table_id,
5240                    row_ids: row_ids.into_iter().map(crate::RowId).collect(),
5241                },
5242            };
5243            records.push(crate::wal::Record::new(Epoch(0), txn_id, op));
5244        }
5245        let epoch = self.epoch.visible().0 + 1;
5246        // The physical component of the decision's commit timestamp goes into
5247        // the durable timestamp ledger, mirroring the ordinary commit path
5248        // (`commit_log::commit_nanos`).
5249        let unix_nanos = commit_ts.physical_micros.saturating_mul(1_000);
5250        records.push(crate::wal::Record::new(
5251            Epoch(0),
5252            txn_id,
5253            Op::CommitTimestamp { unix_nanos },
5254        ));
5255        records.push(crate::wal::Record::new(
5256            Epoch(0),
5257            txn_id,
5258            Op::TxnCommit {
5259                epoch,
5260                added_runs: Vec::new(),
5261            },
5262        ));
5263        let applied = self.apply_replicated_records(&records)?;
5264        if applied && commit_ts != mongreldb_types::hlc::HlcTimestamp::ZERO {
5265            // Ledger + recovery restamp use the shared decision HLC so every
5266            // replica observes the identical commit_ts (P0.5-T4 / P0.5-X1).
5267            self.record_commit_ts(Epoch(epoch), commit_ts);
5268        }
5269        Ok(applied)
5270    }
5271
5272    /// Apply a committed distributed transaction at a shared `commit_ts`
5273    /// (P0.5-T4). Replicas must not allocate a local replacement timestamp —
5274    /// the caller's decision HLC goes into the durable ledger, and every row
5275    /// version recovered from the synthetic WAL records is restamped from the
5276    /// transaction's `Op::CommitTimestamp` record (physical micros; the WAL
5277    /// `Put` payload cannot carry the full HLC under the 0.63.1 layout).
5278    pub fn apply_committed_transaction(
5279        &self,
5280        transaction_id: u64,
5281        commit_ts: mongreldb_types::hlc::HlcTimestamp,
5282        operations: &[Vec<u8>],
5283    ) -> Result<bool> {
5284        self.apply_staged_txn_writes(transaction_id, operations, commit_ts)
5285    }
5286
5287    /// Stage 2E (spec sections 10.6, 11.5): apply one committed replicated
5288    /// catalog command and checkpoint the catalog. The record travels as the
5289    /// payload of a replicated `Catalog` command envelope and routes through
5290    /// [`Catalog::apply_command`] — the S1F-001 versioned, idempotent command
5291    /// path (replaying an already-applied `catalog_version` is a no-op).
5292    /// Structural deltas are mirrored into the mounted table set: a created
5293    /// table is mounted, a dropped table unmounted. Deterministic: the record
5294    /// carries every resolved value (ids, epochs, complete images).
5295    pub fn apply_replicated_catalog_command(
5296        &self,
5297        record: &crate::catalog_cmds::CatalogCommandRecord,
5298    ) -> Result<crate::catalog_cmds::CatalogDelta> {
5299        let _operation = self.admit_operation()?;
5300        let _g = self.ddl_lock.lock();
5301        let mut next_catalog = self.catalog.read().clone();
5302        let delta = next_catalog.apply_command(record)?;
5303        if matches!(delta, crate::catalog_cmds::CatalogDelta::NoOp) {
5304            return Ok(delta);
5305        }
5306        // The leader references epochs in structural commands (a table's
5307        // creation/drop epoch). The replica's epoch stream must cover them:
5308        // epochs stay the commit sequencer's authority, so commands never
5309        // allocate one, but the watermark advances to every referenced epoch
5310        // (mirroring how `create_table_with_state` bumps `db_epoch`).
5311        let referenced_epoch = match &delta {
5312            crate::catalog_cmds::CatalogDelta::TableCreated { entry } => Some(entry.created_epoch),
5313            crate::catalog_cmds::CatalogDelta::TableDropped { at_epoch, .. }
5314            | crate::catalog_cmds::CatalogDelta::TableRenamed { at_epoch, .. } => Some(*at_epoch),
5315            _ => None,
5316        };
5317        if let Some(referenced) = referenced_epoch {
5318            self.epoch.advance_recovered(Epoch(referenced));
5319            next_catalog.db_epoch = next_catalog.db_epoch.max(referenced);
5320        }
5321        match &delta {
5322            crate::catalog_cmds::CatalogDelta::TableCreated { entry } => {
5323                // Guard against a repeated mount within one open (the state
5324                // machine's crash-window redispatch is filtered by the NoOp
5325                // arm above; this covers a catalog checkpoint that never
5326                // became durable before a crash).
5327                if !self.tables.read().contains_key(&entry.table_id) {
5328                    self.mount_catalog_entry(entry)?;
5329                }
5330            }
5331            crate::catalog_cmds::CatalogDelta::TableDropped { table_id, .. } => {
5332                self.tables.write().remove(table_id);
5333            }
5334            _ => {}
5335        }
5336        // Durable BEFORE return: the catalog checkpoint is the only local
5337        // durable record of the command (replicated catalog commands do not
5338        // ride the local WAL), and the state machine checkpoints right after.
5339        catalog::write_atomic(&self.root, &next_catalog, self.meta_dek.as_ref())?;
5340        *self.catalog.write() = next_catalog;
5341        Ok(delta)
5342    }
5343
5344    /// Mount a table that a replicated catalog command created (Stage 2E):
5345    /// create the table directory and build the mounted table exactly like
5346    /// the create-table path does, minus the standalone WAL/commit side
5347    /// effects (the command itself is already durable in the raft log).
5348    fn mount_catalog_entry(&self, entry: &crate::catalog::CatalogEntry) -> Result<()> {
5349        let table_relative = Path::new(TABLES_DIR).join(entry.table_id.to_string());
5350        let table_root = Arc::new(
5351            self.durable_root
5352                .create_directory_all_pinned(&table_relative)?,
5353        );
5354        let tdir = table_root.io_path()?;
5355        let ctx = SharedCtx {
5356            root_guard: Some(table_root),
5357            epoch: Arc::clone(&self.epoch),
5358            page_cache: Arc::clone(&self.page_cache),
5359            decoded_cache: Arc::clone(&self.decoded_cache),
5360            snapshots: Arc::clone(&self.snapshots),
5361            kek: self.kek.clone(),
5362            commit_lock: Arc::clone(&self.commit_lock),
5363            shared: Some(crate::engine::SharedWalCtx {
5364                wal: Arc::clone(&self.shared_wal),
5365                group: Arc::clone(&self.group),
5366                poisoned: Arc::clone(&self.poisoned),
5367                txn_ids: Arc::clone(&self.next_txn_id),
5368                change_wake: self.change_wake.clone(),
5369                lifecycle: Arc::clone(&self.lifecycle),
5370                hlc: Arc::clone(&self.hlc),
5371            }),
5372            table_name: Some(entry.name.clone()),
5373            auth: self.table_auth_checker(),
5374            read_only: self.read_only,
5375        };
5376        let table = Table::create_in(&tdir, entry.schema.clone(), entry.table_id, ctx)?;
5377        self.tables
5378            .write()
5379            .insert(entry.table_id, TableHandle::new(table));
5380        Ok(())
5381    }
5382
5383    fn publish_catalog_candidate(
5384        &self,
5385        catalog: Catalog,
5386        epoch: Epoch,
5387        epoch_guard: &mut EpochGuard<'_>,
5388        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
5389    ) -> Result<()> {
5390        self.publish_catalog_candidate_with_prelude(
5391            catalog,
5392            epoch,
5393            epoch_guard,
5394            before_publish,
5395            Vec::new(),
5396        )
5397    }
5398
5399    fn publish_catalog_candidate_with_prelude(
5400        &self,
5401        catalog: Catalog,
5402        epoch: Epoch,
5403        epoch_guard: &mut EpochGuard<'_>,
5404        mut before_publish: Option<&mut dyn FnMut() -> Result<()>>,
5405        prelude: Vec<(u64, crate::wal::Op)>,
5406    ) -> Result<()> {
5407        use crate::wal::DdlOp;
5408
5409        if self.read_only {
5410            return Err(MongrelError::ReadOnlyReplica);
5411        }
5412        if self.poisoned.load(Ordering::Relaxed) {
5413            return Err(MongrelError::Other(
5414                "database poisoned by fsync error".into(),
5415            ));
5416        }
5417        // S1A-004: admit the catalog publish as one core operation.
5418        let _operation = self.admit_operation()?;
5419        if let Some(before_publish) = before_publish.as_mut() {
5420            (**before_publish)()?;
5421        }
5422        if catalog.db_epoch != epoch.0 {
5423            return Err(MongrelError::InvalidArgument(format!(
5424                "catalog epoch {} does not match commit epoch {}",
5425                catalog.db_epoch, epoch.0
5426            )));
5427        }
5428        {
5429            let current = self.catalog.read();
5430            validate_catalog_transition(&current, &catalog)?;
5431        }
5432        validate_recovered_catalog(&catalog)?;
5433        let catalog_json = DdlOp::encode_catalog(&catalog)?;
5434        let txn_id = self.alloc_txn_id()?;
5435        let commit_seq = {
5436            let mut wal = self.shared_wal.lock();
5437            let append: Result<u64> = (|| {
5438                for (table_id, op) in prelude {
5439                    wal.append(txn_id, table_id, op)?;
5440                }
5441                wal.append(
5442                    txn_id,
5443                    WAL_TABLE_ID,
5444                    crate::wal::Op::Ddl(DdlOp::CatalogSnapshot { catalog_json }),
5445                )?;
5446                wal.append_commit(txn_id, epoch, &[])
5447            })();
5448            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
5449        };
5450        let receipt = self.await_durable_commit(txn_id, commit_seq, epoch)?;
5451        let checkpoint = self.checkpoint_catalog_after_durable(catalog);
5452        self.finish_durable_publish(epoch, epoch_guard, &receipt, checkpoint)
5453    }
5454
5455    /// A WAL commit is already durable. Publish the matching catalog in memory
5456    /// even when its checkpoint rewrite fails; recovery can rebuild the file,
5457    /// while the live handle must never continue with pre-commit metadata.
5458    fn checkpoint_catalog_after_durable(&self, catalog: Catalog) -> Result<()> {
5459        let checkpoint = catalog::write_atomic(&self.root, &catalog, self.meta_dek.as_ref());
5460        let version = catalog.security_version;
5461        let principal = self.principal.read().clone();
5462        let principal = if catalog.require_auth
5463            && principal
5464                .as_ref()
5465                .is_some_and(|principal| principal.user_id != 0)
5466        {
5467            principal.as_ref().and_then(|principal| {
5468                Self::resolve_bound_principal_from_catalog(&catalog, principal)
5469            })
5470        } else {
5471            principal
5472        };
5473        *self.catalog.write() = catalog;
5474        self.security_coordinator
5475            .version
5476            .store(version, Ordering::Release);
5477        self.auth_state
5478            .set_require_auth(self.catalog.read().require_auth);
5479        *self.principal.write() = principal.clone();
5480        self.auth_state.set_principal(principal);
5481        checkpoint
5482    }
5483
5484    fn finish_durable_publish(
5485        &self,
5486        epoch: Epoch,
5487        epoch_guard: &mut EpochGuard<'_>,
5488        receipt: &mongreldb_log::CommitReceipt,
5489        post_step: Result<()>,
5490    ) -> Result<()> {
5491        if let Err(error) = self.publish_committed(receipt, epoch) {
5492            // The commit marker is durable but runtime publication failed. The
5493            // epoch guard stays armed so the assigned ticket is abandoned (the
5494            // watermark skips it), and the live handle poisons exactly like any
5495            // other post-durable failure.
5496            self.poisoned.store(true, Ordering::Relaxed);
5497            self.lifecycle.poison();
5498            return Err(MongrelError::DurableCommit {
5499                epoch: epoch.0,
5500                message: error.to_string(),
5501            });
5502        }
5503        epoch_guard.disarm();
5504        match post_step {
5505            Ok(()) => Ok(()),
5506            Err(error) => {
5507                self.poisoned.store(true, Ordering::Relaxed);
5508                self.lifecycle.poison();
5509                Err(MongrelError::DurableCommit {
5510                    epoch: epoch.0,
5511                    message: error.to_string(),
5512                })
5513            }
5514        }
5515    }
5516
5517    /// Advance reader visibility for a committed epoch. Publication is gated on
5518    /// the commit log's receipt (spec §9.4, FND-004): visibility only ever
5519    /// covers commands the commit log acknowledged durable. The
5520    /// `commit.publish.before`/`commit.publish.after` fault hooks bracket the
5521    /// watermark advance (spec §9.6, FND-006).
5522    fn publish_committed(
5523        &self,
5524        receipt: &mongreldb_log::CommitReceipt,
5525        epoch: Epoch,
5526    ) -> Result<()> {
5527        debug_assert_eq!(
5528            receipt.log_position.index, epoch.0,
5529            "commit receipt position must match the published epoch"
5530        );
5531        mongreldb_fault::inject("commit.publish.before").map_err(crate::commit_log::fault_as_io)?;
5532        self.epoch.publish_in_order(epoch);
5533        mongreldb_fault::inject("commit.publish.after").map_err(crate::commit_log::fault_as_io)?;
5534        Ok(())
5535    }
5536
5537    /// Wait for a commit marker to reach stable storage and return the commit
5538    /// log's irrevocable receipt (spec §9.4, FND-004). A failed append/fsync
5539    /// acknowledgement is ambiguous, so poison the live handle and preserve
5540    /// the assigned epoch in a structured unknown-outcome error.
5541    ///
5542    /// Used by the DDL/maintenance commit paths, which do not pre-assign a
5543    /// commit timestamp: the receipt's `commit_ts` is allocated from the
5544    /// node's HLC clock at seal time.
5545    fn await_durable_commit(
5546        &self,
5547        txn_id: u64,
5548        commit_seq: u64,
5549        epoch: Epoch,
5550    ) -> Result<mongreldb_log::CommitReceipt> {
5551        match self
5552            .standalone_commit_log
5553            .seal_transaction(txn_id, epoch, commit_seq, None)
5554        {
5555            Ok(receipt) => {
5556                self.record_commit_ts(epoch, receipt.commit_ts);
5557                Ok(receipt)
5558            }
5559            Err(error) => {
5560                self.poisoned.store(true, Ordering::Relaxed);
5561                self.lifecycle.poison();
5562                Err(MongrelError::CommitOutcomeUnknown {
5563                    epoch: epoch.0,
5564                    message: error.to_string(),
5565                })
5566            }
5567        }
5568    }
5569
5570    /// [`Self::await_durable_commit`] for the transaction commit sequencer,
5571    /// which assigned `commit_ts` under the sequencer lock (S1B-004 step 5,
5572    /// spec §8.2). The receipt carries that exact timestamp, matching the
5573    /// durable `Op::CommitTimestamp` ledger record written at append.
5574    fn await_durable_commit_with_ts(
5575        &self,
5576        txn_id: u64,
5577        commit_seq: u64,
5578        epoch: Epoch,
5579        commit_ts: mongreldb_types::hlc::HlcTimestamp,
5580    ) -> Result<mongreldb_log::CommitReceipt> {
5581        match self.standalone_commit_log.seal_transaction(
5582            txn_id,
5583            epoch,
5584            commit_seq,
5585            Some(commit_ts),
5586        ) {
5587            Ok(receipt) => {
5588                self.record_commit_ts(epoch, receipt.commit_ts);
5589                Ok(receipt)
5590            }
5591            Err(error) => {
5592                self.poisoned.store(true, Ordering::Relaxed);
5593                self.lifecycle.poison();
5594                Err(MongrelError::CommitOutcomeUnknown {
5595                    epoch: epoch.0,
5596                    message: error.to_string(),
5597                })
5598            }
5599        }
5600    }
5601
5602    /// Record one durable commit's receipt timestamp in the per-open ledger
5603    /// (bounded to the newest [`COMMIT_TS_LEDGER_CAP`] epochs). Called by the
5604    /// durability funnels once the commit log has issued the irrevocable
5605    /// receipt.
5606    fn record_commit_ts(&self, epoch: Epoch, commit_ts: mongreldb_types::hlc::HlcTimestamp) {
5607        let mut ledger = self.commit_ts_ledger.lock();
5608        ledger.insert(epoch.0, commit_ts);
5609        while ledger.len() > COMMIT_TS_LEDGER_CAP {
5610            ledger.pop_first();
5611        }
5612    }
5613
5614    /// The commit timestamp of a durable commit, by epoch — the literal
5615    /// write receipt behind the server's read-your-writes token (spec §8.2).
5616    ///
5617    /// Returns `Some` for commits sealed within this open (the exact receipt
5618    /// `HlcTimestamp`) and for commits recovered from the durable
5619    /// `Op::CommitTimestamp` WAL ledger at open; the latter reconstruct the
5620    /// physical component only, with `logical` and `node_tiebreaker` as 0 per
5621    /// the ledger byte format. Only the newest [`COMMIT_TS_LEDGER_CAP`]
5622    /// epochs are retained, and epochs sealed through `CommitLog::propose`
5623    /// (catalog-command proposals) are not recorded here within a live open;
5624    /// both miss shapes return `None`, and callers fall back to a fresh-begin
5625    /// HLC, which the single clock authority orders after every commit it has
5626    /// already issued.
5627    pub fn commit_ts_for_epoch(&self, epoch: Epoch) -> Option<mongreldb_types::hlc::HlcTimestamp> {
5628        self.commit_ts_ledger.lock().get(&epoch.0).copied()
5629    }
5630
5631    /// Newest retained commit epoch whose HLC timestamp is not after `timestamp`.
5632    ///
5633    /// The mapping is bounded by [`COMMIT_TS_LEDGER_CAP`]. Callers needing an
5634    /// older historical point must treat `None` as unavailable, never guess an
5635    /// epoch.
5636    pub fn epoch_at_or_before_commit_ts(
5637        &self,
5638        timestamp: mongreldb_types::hlc::HlcTimestamp,
5639    ) -> Option<Epoch> {
5640        self.commit_ts_ledger
5641            .lock()
5642            .iter()
5643            .rev()
5644            .find_map(|(epoch, commit_ts)| (*commit_ts <= timestamp).then_some(Epoch(*epoch)))
5645    }
5646
5647    fn commit_outcome_unknown(&self, epoch: Epoch, error: impl std::fmt::Display) -> MongrelError {
5648        self.poisoned.store(true, Ordering::Relaxed);
5649        self.lifecycle.poison();
5650        MongrelError::CommitOutcomeUnknown {
5651            epoch: epoch.0,
5652            message: error.to_string(),
5653        }
5654    }
5655
5656    /// Persist a complete validated RLS/masking catalog through the WAL.
5657    pub fn set_security_catalog(&self, security: crate::security::SecurityCatalog) -> Result<()> {
5658        self.set_security_catalog_as_with_epoch(security, None)
5659            .map(|_| ())
5660    }
5661
5662    /// Persist security policy changes on behalf of an explicit request principal.
5663    pub fn set_security_catalog_as(
5664        &self,
5665        security: crate::security::SecurityCatalog,
5666        principal: Option<&crate::auth::Principal>,
5667    ) -> Result<()> {
5668        self.set_security_catalog_as_with_epoch(security, principal)
5669            .map(|_| ())
5670    }
5671
5672    /// Persist security policy changes and return the exact publication epoch.
5673    pub fn set_security_catalog_as_with_epoch(
5674        &self,
5675        security: crate::security::SecurityCatalog,
5676        principal: Option<&crate::auth::Principal>,
5677    ) -> Result<Epoch> {
5678        self.set_security_catalog_as_with_epoch_inner(security, principal, None)
5679    }
5680
5681    /// Persist security policy changes, entering the commit fence immediately
5682    /// before the first WAL record can become visible to recovery.
5683    pub fn set_security_catalog_as_with_epoch_controlled<F>(
5684        &self,
5685        security: crate::security::SecurityCatalog,
5686        principal: Option<&crate::auth::Principal>,
5687        mut before_commit: F,
5688    ) -> Result<Epoch>
5689    where
5690        F: FnMut() -> Result<()>,
5691    {
5692        self.set_security_catalog_as_with_epoch_inner(security, principal, Some(&mut before_commit))
5693    }
5694
5695    fn set_security_catalog_as_with_epoch_inner(
5696        &self,
5697        security: crate::security::SecurityCatalog,
5698        principal: Option<&crate::auth::Principal>,
5699        before_commit: Option<&mut dyn FnMut() -> Result<()>>,
5700    ) -> Result<Epoch> {
5701        use crate::wal::DdlOp;
5702        use std::sync::atomic::Ordering;
5703
5704        // S1F-001: the mutation is a versioned catalog command; its required
5705        // permission is checked against the caller principal first (identical
5706        // to the legacy hardcoded Admin gate).
5707        let command = crate::catalog_cmds::CatalogCommand::SetSecurityCatalog {
5708            security: security.clone(),
5709        };
5710        self.require_for(
5711            principal,
5712            &crate::catalog_cmds::required_permission(&command),
5713        )?;
5714        if self.poisoned.load(Ordering::Relaxed) {
5715            return Err(MongrelError::Other(
5716                "database poisoned by fsync error".into(),
5717            ));
5718        }
5719        // S1A-004: admit the security-catalog replacement as one core
5720        // operation.
5721        let _operation = self.admit_operation()?;
5722        let _ddl = self.ddl_lock.lock();
5723        // DDL serializes first; write-path order after that is security gate ->
5724        // commit lock -> shared WAL.
5725        let _security_write = self.security_write()?;
5726        self.require_for(
5727            principal,
5728            &crate::catalog_cmds::required_permission(&command),
5729        )?;
5730        let mut next_catalog = self.catalog.read().clone();
5731        validate_security_catalog(&next_catalog, &security)?;
5732        let payload = DdlOp::encode_security(&security)?;
5733        let _commit = self.commit_lock.lock();
5734        let epoch = self.epoch.bump_assigned();
5735        let mut epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
5736        let txn_id = self.alloc_txn_id()?;
5737        self.apply_catalog_command_to(&mut next_catalog, command)?;
5738        next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
5739        let commit_seq = {
5740            let mut wal = self.shared_wal.lock();
5741            if let Some(before_commit) = before_commit {
5742                before_commit()?;
5743            }
5744            let append: Result<u64> = (|| {
5745                wal.append(
5746                    txn_id,
5747                    WAL_TABLE_ID,
5748                    crate::wal::Op::Ddl(DdlOp::SetSecurityCatalog {
5749                        security_json: payload,
5750                    }),
5751                )?;
5752                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
5753                wal.append_commit(txn_id, epoch, &[])
5754            })();
5755            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
5756        };
5757        let receipt = self.await_durable_commit(txn_id, commit_seq, epoch)?;
5758        let checkpoint = self.checkpoint_catalog_after_durable(next_catalog);
5759        self.finish_durable_publish(epoch, &mut epoch_guard, &receipt, checkpoint)?;
5760        Ok(epoch)
5761    }
5762
5763    pub fn require_for(
5764        &self,
5765        principal: Option<&crate::auth::Principal>,
5766        permission: &crate::auth::Permission,
5767    ) -> Result<()> {
5768        let Some(principal) = principal else {
5769            return self.require(permission);
5770        };
5771        let resolved;
5772        let principal = if principal.user_id != 0 {
5773            resolved = Self::resolve_bound_principal_from_catalog(&self.catalog.read(), principal)
5774                .ok_or(MongrelError::AuthRequired)?;
5775            &resolved
5776        } else {
5777            principal
5778        };
5779        #[cfg(test)]
5780        TABLE_PERMISSION_DECISIONS.with(|decisions| decisions.set(decisions.get() + 1));
5781        if principal.has_permission(permission) {
5782            Ok(())
5783        } else {
5784            Err(MongrelError::PermissionDenied {
5785                required: permission.clone(),
5786                principal: principal.username.clone(),
5787            })
5788        }
5789    }
5790
5791    /// Recheck the exact operation principal while the caller holds the
5792    /// security gate. This deliberately performs no refresh or nested gate
5793    /// acquisition.
5794    fn require_exact_principal_current(
5795        &self,
5796        principal: Option<&crate::auth::Principal>,
5797        permission: &crate::auth::Permission,
5798    ) -> Result<()> {
5799        let catalog = self.catalog.read();
5800        if !catalog.require_auth {
5801            return Ok(());
5802        }
5803        let supplied = principal.ok_or(MongrelError::AuthRequired)?;
5804        let current = if supplied.user_id == 0 {
5805            supplied.clone()
5806        } else {
5807            Self::resolve_bound_principal_from_catalog(&catalog, supplied)
5808                .ok_or(MongrelError::AuthRequired)?
5809        };
5810        if current.has_permission(permission) {
5811            Ok(())
5812        } else {
5813            Err(MongrelError::PermissionDenied {
5814                required: permission.clone(),
5815                principal: current.username,
5816            })
5817        }
5818    }
5819
5820    pub(crate) fn with_exact_principal_current<T, F>(
5821        &self,
5822        principal: Option<&crate::auth::Principal>,
5823        permission: &crate::auth::Permission,
5824        operation: F,
5825    ) -> Result<T>
5826    where
5827        F: FnOnce() -> Result<T>,
5828    {
5829        let _security = self.security_coordinator.gate.read();
5830        self.require_exact_principal_current(principal, permission)?;
5831        operation()
5832    }
5833
5834    pub fn principal_snapshot(&self) -> Option<crate::auth::Principal> {
5835        self.principal.read().clone()
5836    }
5837
5838    #[cfg(test)]
5839    pub(crate) fn set_cached_principal_for_test(&self, principal: Option<crate::auth::Principal>) {
5840        *self.principal.write() = principal.clone();
5841        self.auth_state.set_principal(principal);
5842    }
5843
5844    pub fn require_columns_for(
5845        &self,
5846        table: &str,
5847        operation: crate::auth::ColumnOperation,
5848        column_ids: &[u16],
5849        principal: Option<&crate::auth::Principal>,
5850    ) -> Result<()> {
5851        if principal.is_none() && !self.auth_state.require_auth() {
5852            return Ok(());
5853        }
5854        let cached = self.principal.read().clone();
5855        let principal = principal.or(cached.as_ref());
5856        let Some(principal) = principal else {
5857            let permission = match operation {
5858                crate::auth::ColumnOperation::Select => crate::auth::Permission::Select {
5859                    table: table.to_string(),
5860                },
5861                crate::auth::ColumnOperation::Insert => crate::auth::Permission::Insert {
5862                    table: table.to_string(),
5863                },
5864                crate::auth::ColumnOperation::Update => crate::auth::Permission::Update {
5865                    table: table.to_string(),
5866                },
5867            };
5868            return self.require(&permission);
5869        };
5870        let catalog = self.catalog.read();
5871        let resolved;
5872        let principal = if principal.user_id != 0 {
5873            resolved = Self::resolve_bound_principal_from_catalog(&catalog, principal)
5874                .ok_or(MongrelError::AuthRequired)?;
5875            &resolved
5876        } else {
5877            principal
5878        };
5879        let schema = &catalog
5880            .live(table)
5881            .ok_or_else(|| MongrelError::NotFound(format!("table {table:?} not found")))?
5882            .schema;
5883        Self::require_columns_for_principal(table, schema, operation, column_ids, principal)
5884    }
5885
5886    fn require_columns_for_principal(
5887        table: &str,
5888        schema: &Schema,
5889        operation: crate::auth::ColumnOperation,
5890        column_ids: &[u16],
5891        principal: &crate::auth::Principal,
5892    ) -> Result<()> {
5893        #[cfg(test)]
5894        WRITE_PERMISSION_DECISIONS.with(|decisions| decisions.set(decisions.get() + 1));
5895        match principal.column_access(table, operation) {
5896            crate::auth::ColumnAccess::All => Ok(()),
5897            crate::auth::ColumnAccess::Columns(allowed) => {
5898                let denied = column_ids.iter().find_map(|column_id| {
5899                    schema
5900                        .columns
5901                        .iter()
5902                        .find(|column| column.id == *column_id)
5903                        .filter(|column| !allowed.contains(&column.name))
5904                });
5905                if denied.is_none() {
5906                    Ok(())
5907                } else {
5908                    Err(MongrelError::PermissionDenied {
5909                        required: match operation {
5910                            crate::auth::ColumnOperation::Select => {
5911                                crate::auth::Permission::SelectColumns {
5912                                    table: table.to_string(),
5913                                    columns: denied
5914                                        .into_iter()
5915                                        .map(|column| column.name.clone())
5916                                        .collect(),
5917                                }
5918                            }
5919                            crate::auth::ColumnOperation::Insert => {
5920                                crate::auth::Permission::InsertColumns {
5921                                    table: table.to_string(),
5922                                    columns: denied
5923                                        .into_iter()
5924                                        .map(|column| column.name.clone())
5925                                        .collect(),
5926                                }
5927                            }
5928                            crate::auth::ColumnOperation::Update => {
5929                                crate::auth::Permission::UpdateColumns {
5930                                    table: table.to_string(),
5931                                    columns: denied
5932                                        .into_iter()
5933                                        .map(|column| column.name.clone())
5934                                        .collect(),
5935                                }
5936                            }
5937                        },
5938                        principal: principal.username.clone(),
5939                    })
5940                }
5941            }
5942            crate::auth::ColumnAccess::Denied => Err(MongrelError::PermissionDenied {
5943                required: match operation {
5944                    crate::auth::ColumnOperation::Select => crate::auth::Permission::Select {
5945                        table: table.to_string(),
5946                    },
5947                    crate::auth::ColumnOperation::Insert => crate::auth::Permission::Insert {
5948                        table: table.to_string(),
5949                    },
5950                    crate::auth::ColumnOperation::Update => crate::auth::Permission::Update {
5951                        table: table.to_string(),
5952                    },
5953                },
5954                principal: principal.username.clone(),
5955            }),
5956        }
5957    }
5958
5959    pub fn select_column_ids_for(
5960        &self,
5961        table: &str,
5962        principal: Option<&crate::auth::Principal>,
5963    ) -> Result<Vec<u16>> {
5964        let catalog = self.catalog.read();
5965        let columns = catalog
5966            .live(table)
5967            .ok_or_else(|| MongrelError::NotFound(format!("table {table:?} not found")))?
5968            .schema
5969            .columns
5970            .iter()
5971            .map(|column| (column.id, column.name.clone()))
5972            .collect::<Vec<_>>();
5973        let principal = self.principal_for_authorized_read(&catalog, principal, false)?;
5974        drop(catalog);
5975        let Some(principal) = principal.as_ref() else {
5976            self.require(&crate::auth::Permission::Select {
5977                table: table.to_string(),
5978            })?;
5979            return Ok(columns.iter().map(|(id, _)| *id).collect());
5980        };
5981        match principal.column_access(table, crate::auth::ColumnOperation::Select) {
5982            crate::auth::ColumnAccess::All => Ok(columns.iter().map(|(id, _)| *id).collect()),
5983            crate::auth::ColumnAccess::Columns(allowed) => Ok(columns
5984                .iter()
5985                .filter(|(_, name)| allowed.contains(name))
5986                .map(|(id, _)| *id)
5987                .collect()),
5988            crate::auth::ColumnAccess::Denied => Err(MongrelError::PermissionDenied {
5989                required: crate::auth::Permission::Select {
5990                    table: table.to_string(),
5991                },
5992                principal: principal.username.clone(),
5993            }),
5994        }
5995    }
5996
5997    pub fn secure_rows_for(
5998        &self,
5999        table: &str,
6000        rows: Vec<crate::memtable::Row>,
6001        principal: Option<&crate::auth::Principal>,
6002    ) -> Result<Vec<crate::memtable::Row>> {
6003        self.secure_rows_for_with_context(table, rows, principal, None)
6004    }
6005
6006    pub fn secure_rows_for_with_context(
6007        &self,
6008        table: &str,
6009        rows: Vec<crate::memtable::Row>,
6010        principal: Option<&crate::auth::Principal>,
6011        context: Option<&crate::query::AiExecutionContext>,
6012    ) -> Result<Vec<crate::memtable::Row>> {
6013        let (security, principal) = {
6014            let catalog = self.catalog.read();
6015            (
6016                catalog.security.clone(),
6017                self.principal_for_authorized_read(&catalog, principal, false)?,
6018            )
6019        };
6020        if !security.table_has_security(table) {
6021            return Ok(rows);
6022        }
6023        let principal = principal.as_ref().ok_or(MongrelError::AuthRequired)?;
6024        let mut output = Vec::new();
6025        for mut row in rows {
6026            if let Some(context) = context {
6027                context.consume(1)?;
6028            }
6029            if security.row_allowed(
6030                table,
6031                crate::security::PolicyCommand::Select,
6032                &row,
6033                principal,
6034                false,
6035            ) {
6036                security.apply_masks(table, &mut row, principal);
6037                output.push(row);
6038            }
6039        }
6040        Ok(output)
6041    }
6042
6043    /// Apply column masks to already RLS-authorized scored hits without a
6044    /// second row gather or policy evaluation.
6045    pub fn mask_search_hits_for(
6046        &self,
6047        table: &str,
6048        hits: &mut [crate::query::SearchHit],
6049        principal: Option<&crate::auth::Principal>,
6050    ) -> Result<()> {
6051        let (security, principal) = {
6052            let catalog = self.catalog.read();
6053            (
6054                catalog.security.clone(),
6055                self.principal_for_authorized_read(&catalog, principal, false)?,
6056            )
6057        };
6058        if !security.table_has_security(table) {
6059            return Ok(());
6060        }
6061        let principal = principal.as_ref().ok_or(MongrelError::AuthRequired)?;
6062        for hit in hits {
6063            security.apply_masks_to_cells(table, &mut hit.cells, principal);
6064        }
6065        Ok(())
6066    }
6067
6068    /// Apply masks to rows already admitted by candidate-aware RLS.
6069    pub fn mask_rows_for(
6070        &self,
6071        table: &str,
6072        rows: &mut [crate::memtable::Row],
6073        principal: Option<&crate::auth::Principal>,
6074    ) -> Result<()> {
6075        let (security, principal) = {
6076            let catalog = self.catalog.read();
6077            (
6078                catalog.security.clone(),
6079                self.principal_for_authorized_read(&catalog, principal, false)?,
6080            )
6081        };
6082        if !security.table_has_security(table) {
6083            return Ok(());
6084        }
6085        let principal = principal.as_ref().ok_or(MongrelError::AuthRequired)?;
6086        for row in rows {
6087            security.apply_masks(table, row, principal);
6088        }
6089        Ok(())
6090    }
6091
6092    /// Row IDs allowed to enter scored ranking. `None` means no RLS filter.
6093    pub fn authorized_candidate_ids_for(
6094        &self,
6095        table: &str,
6096        principal: Option<&crate::auth::Principal>,
6097    ) -> Result<Option<std::collections::HashSet<RowId>>> {
6098        Ok(self
6099            .authorized_read_snapshot(table, principal)?
6100            .allowed_row_ids)
6101    }
6102
6103    fn allowed_row_ids_locked(
6104        &self,
6105        table_name: &str,
6106        table: &Table,
6107        table_snapshot: Snapshot,
6108        security_state: (&crate::security::SecurityCatalog, u64),
6109        principal: Option<&crate::auth::Principal>,
6110        context: Option<&crate::query::AiExecutionContext>,
6111    ) -> Result<Option<Arc<HashSet<RowId>>>> {
6112        let (security, security_version) = security_state;
6113        if !security.rls_enabled(table_name) {
6114            return Ok(None);
6115        }
6116        let authorization_started = std::time::Instant::now();
6117        let principal = principal.ok_or(MongrelError::AuthRequired)?;
6118        let mut roles = principal.roles.clone();
6119        roles.sort_unstable();
6120        let principal_key = format!(
6121            "{}:{}:{}:{}:{roles:?}",
6122            principal.user_id, principal.created_epoch, principal.username, principal.is_admin
6123        );
6124        let cache_key = (
6125            table_name.to_string(),
6126            table.data_generation(),
6127            security_version,
6128            principal_key,
6129        );
6130        if let Some(allowed) = self.rls_cache.lock().get(&cache_key) {
6131            crate::trace::QueryTrace::record(|trace| {
6132                trace.rls_cache_hit = true;
6133                trace.authorization_nanos = trace
6134                    .authorization_nanos
6135                    .saturating_add(authorization_started.elapsed().as_nanos() as u64);
6136            });
6137            return Ok(Some(allowed));
6138        }
6139        if let Some(context) = context {
6140            context.checkpoint()?;
6141        }
6142        // ponytail: full RLS universe scan; replace with policy-column candidate checks if RLS search throughput matters.
6143        let started = std::time::Instant::now();
6144        let rows = table.visible_rows(table_snapshot)?;
6145        let rows_evaluated = rows.len() as u64;
6146        let mut allowed = HashSet::new();
6147        for chunk in rows.chunks(256) {
6148            if let Some(context) = context {
6149                context.consume(chunk.len())?;
6150            }
6151            allowed.extend(chunk.iter().filter_map(|row| {
6152                security
6153                    .row_allowed(
6154                        table_name,
6155                        crate::security::PolicyCommand::Select,
6156                        row,
6157                        principal,
6158                        false,
6159                    )
6160                    .then_some(row.row_id)
6161            }));
6162        }
6163        let allowed = Arc::new(allowed);
6164        let mut cache = self.rls_cache.lock();
6165        cache.build_nanos = cache
6166            .build_nanos
6167            .saturating_add(started.elapsed().as_nanos() as u64);
6168        cache.rows_evaluated = cache.rows_evaluated.saturating_add(rows_evaluated);
6169        cache.insert(cache_key, Arc::clone(&allowed));
6170        crate::trace::QueryTrace::record(|trace| {
6171            trace.rls_rows_evaluated = trace
6172                .rls_rows_evaluated
6173                .saturating_add(rows_evaluated as usize);
6174            trace.authorization_nanos = trace
6175                .authorization_nanos
6176                .saturating_add(authorization_started.elapsed().as_nanos() as u64);
6177        });
6178        Ok(Some(allowed))
6179    }
6180
6181    fn principal_for_authorized_read(
6182        &self,
6183        catalog: &Catalog,
6184        principal: Option<&crate::auth::Principal>,
6185        catalog_bound: bool,
6186    ) -> Result<Option<crate::auth::Principal>> {
6187        let principal = principal.cloned().or_else(|| self.principal.read().clone());
6188        let Some(principal) = principal else {
6189            return Ok(None);
6190        };
6191        if catalog_bound || principal.user_id != 0 {
6192            return Self::resolve_bound_principal_from_catalog(catalog, &principal)
6193                .map(Some)
6194                .ok_or(MongrelError::AuthRequired);
6195        }
6196        Ok(Some(principal))
6197    }
6198
6199    /// Run authorization, candidate generation, ranking, and materialization
6200    /// while holding one table generation. Security changes cause a bounded
6201    /// retry before any result is published.
6202    pub fn with_authorized_read<T, F>(
6203        &self,
6204        table_name: &str,
6205        principal: Option<&crate::auth::Principal>,
6206        catalog_bound: bool,
6207        read: F,
6208    ) -> Result<T>
6209    where
6210        F: FnMut(
6211            &mut Table,
6212            Snapshot,
6213            Option<&HashSet<RowId>>,
6214            Option<&crate::auth::Principal>,
6215        ) -> Result<T>,
6216    {
6217        self.with_authorized_read_context(
6218            table_name,
6219            principal,
6220            catalog_bound,
6221            None,
6222            None,
6223            None,
6224            read,
6225        )
6226    }
6227
6228    #[allow(clippy::too_many_arguments)]
6229    pub fn with_authorized_read_context<T, F>(
6230        &self,
6231        table_name: &str,
6232        principal: Option<&crate::auth::Principal>,
6233        catalog_bound: bool,
6234        authorization: Option<&ReadAuthorization>,
6235        context: Option<&crate::query::AiExecutionContext>,
6236        snapshot_override: Option<Snapshot>,
6237        read: F,
6238    ) -> Result<T>
6239    where
6240        F: FnMut(
6241            &mut Table,
6242            Snapshot,
6243            Option<&HashSet<RowId>>,
6244            Option<&crate::auth::Principal>,
6245        ) -> Result<T>,
6246    {
6247        self.with_authorized_read_context_stamped(
6248            table_name,
6249            principal,
6250            catalog_bound,
6251            authorization,
6252            context,
6253            snapshot_override,
6254            read,
6255        )
6256        .map(|(result, _)| result)
6257    }
6258
6259    #[allow(clippy::too_many_arguments)]
6260    pub fn with_authorized_read_context_stamped<T, F>(
6261        &self,
6262        table_name: &str,
6263        principal: Option<&crate::auth::Principal>,
6264        catalog_bound: bool,
6265        authorization: Option<&ReadAuthorization>,
6266        context: Option<&crate::query::AiExecutionContext>,
6267        snapshot_override: Option<Snapshot>,
6268        mut read: F,
6269    ) -> Result<(T, AuthorizedReadStamp)>
6270    where
6271        F: FnMut(
6272            &mut Table,
6273            Snapshot,
6274            Option<&HashSet<RowId>>,
6275            Option<&crate::auth::Principal>,
6276        ) -> Result<T>,
6277    {
6278        if principal.is_none() && self.principal.read().is_some() {
6279            self.refresh_principal()?;
6280        }
6281        const RETRIES: usize = 3;
6282        let handle = self.table(table_name)?;
6283        for attempt in 0..RETRIES {
6284            crate::trace::QueryTrace::record(|trace| {
6285                trace.authorization_retries = attempt;
6286            });
6287            let (security, security_version, effective_principal) = {
6288                let catalog = self.catalog.read();
6289                (
6290                    catalog.security.clone(),
6291                    catalog.security_version,
6292                    self.principal_for_authorized_read(&catalog, principal, catalog_bound)?,
6293                )
6294            };
6295            if let Some(authorization) = authorization {
6296                for permission in &authorization.permissions {
6297                    self.require_for(effective_principal.as_ref(), permission)?;
6298                }
6299                self.require_columns_for(
6300                    table_name,
6301                    authorization.operation,
6302                    &authorization.columns,
6303                    effective_principal.as_ref(),
6304                )?;
6305            }
6306            let result = {
6307                let mut table = lock_table_with_context(&handle, context)?;
6308                let snapshot = snapshot_override.unwrap_or_else(|| table.snapshot());
6309                let allowed = self.allowed_row_ids_locked(
6310                    table_name,
6311                    &table,
6312                    snapshot,
6313                    (&security, security_version),
6314                    effective_principal.as_ref(),
6315                    context,
6316                )?;
6317                let stamp = AuthorizedReadStamp {
6318                    table_id: table.table_id(),
6319                    schema_id: table.schema().schema_id,
6320                    data_generation: table.data_generation(),
6321                    security_version,
6322                    snapshot,
6323                };
6324                let result = read(
6325                    &mut table,
6326                    snapshot,
6327                    allowed.as_deref(),
6328                    effective_principal.as_ref(),
6329                )?;
6330                (result, stamp)
6331            };
6332            if let Some(context) = context {
6333                context.checkpoint()?;
6334            }
6335            if self.catalog.read().security_version == security_version {
6336                return Ok(result);
6337            }
6338            if attempt + 1 == RETRIES {
6339                return Err(MongrelError::Conflict(
6340                    "security policy changed during scored read".into(),
6341                ));
6342            }
6343        }
6344        Err(MongrelError::Conflict(
6345            "authorization retry loop exhausted".into(),
6346        ))
6347    }
6348
6349    fn with_authorized_aggregate_table<T, F>(
6350        &self,
6351        table_name: &str,
6352        columns: &[u16],
6353        principal: Option<&crate::auth::Principal>,
6354        catalog_bound: bool,
6355        allow_table_security: bool,
6356        mut aggregate: F,
6357    ) -> Result<T>
6358    where
6359        F: FnMut(
6360            &mut Table,
6361            Option<&crate::security::CandidateAuthorization<'_>>,
6362            Option<&crate::auth::Principal>,
6363            u64,
6364        ) -> Result<T>,
6365    {
6366        if principal.is_none() && self.principal.read().is_some() {
6367            self.refresh_principal()?;
6368        }
6369        const RETRIES: usize = 3;
6370        let handle = self.table(table_name)?;
6371        for attempt in 0..RETRIES {
6372            let (security, security_version, effective_principal) = {
6373                let catalog = self.catalog.read();
6374                (
6375                    catalog.security.clone(),
6376                    catalog.security_version,
6377                    self.principal_for_authorized_read(&catalog, principal, catalog_bound)?,
6378                )
6379            };
6380            self.require_columns_for(
6381                table_name,
6382                crate::auth::ColumnOperation::Select,
6383                columns,
6384                effective_principal.as_ref(),
6385            )?;
6386            if !allow_table_security && security.table_has_security(table_name) {
6387                return Err(MongrelError::InvalidArgument(
6388                    "incremental aggregate is unsupported while RLS or column masks are active"
6389                        .into(),
6390                ));
6391            }
6392            let result = {
6393                let mut table = handle.lock();
6394                let authorization = if security.rls_enabled(table_name) {
6395                    Some(crate::security::CandidateAuthorization {
6396                        table: table_name,
6397                        security: &security,
6398                        principal: effective_principal
6399                            .as_ref()
6400                            .ok_or(MongrelError::AuthRequired)?,
6401                    })
6402                } else {
6403                    None
6404                };
6405                aggregate(
6406                    &mut table,
6407                    authorization.as_ref(),
6408                    effective_principal.as_ref(),
6409                    security_version,
6410                )?
6411            };
6412            if self.catalog.read().security_version == security_version {
6413                return Ok(result);
6414            }
6415            if attempt + 1 == RETRIES {
6416                return Err(MongrelError::Conflict(
6417                    "security policy changed during aggregate read".into(),
6418                ));
6419            }
6420        }
6421        Err(MongrelError::Conflict(
6422            "aggregate authorization retry loop exhausted".into(),
6423        ))
6424    }
6425
6426    /// Scored-read authorization that evaluates RLS only for approximate
6427    /// candidates. This avoids a full-table policy scan on cache misses while
6428    /// preserving one table generation and security-version retry.
6429    pub fn with_authorized_scored_read_context<T, F>(
6430        &self,
6431        table_name: &str,
6432        principal: Option<&crate::auth::Principal>,
6433        catalog_bound: bool,
6434        authorization: Option<&ReadAuthorization>,
6435        context: Option<&crate::query::AiExecutionContext>,
6436        mut read: F,
6437    ) -> Result<T>
6438    where
6439        F: FnMut(
6440            &mut Table,
6441            Snapshot,
6442            Option<&crate::security::CandidateAuthorization<'_>>,
6443            Option<&crate::auth::Principal>,
6444        ) -> Result<T>,
6445    {
6446        self.with_authorized_scored_read_context_at(
6447            table_name,
6448            principal,
6449            catalog_bound,
6450            authorization,
6451            context,
6452            None,
6453            |table, snapshot, authorization, principal| {
6454                let mut table = table.clone();
6455                read(&mut table, snapshot, authorization, principal)
6456            },
6457        )
6458    }
6459
6460    #[allow(clippy::too_many_arguments)]
6461    pub fn with_authorized_scored_read_context_at<T, F>(
6462        &self,
6463        table_name: &str,
6464        principal: Option<&crate::auth::Principal>,
6465        catalog_bound: bool,
6466        authorization: Option<&ReadAuthorization>,
6467        context: Option<&crate::query::AiExecutionContext>,
6468        snapshot_override: Option<Snapshot>,
6469        read: F,
6470    ) -> Result<T>
6471    where
6472        F: FnMut(
6473            &Table,
6474            Snapshot,
6475            Option<&crate::security::CandidateAuthorization<'_>>,
6476            Option<&crate::auth::Principal>,
6477        ) -> Result<T>,
6478    {
6479        self.with_authorized_scored_read_context_at_stamped(
6480            table_name,
6481            principal,
6482            catalog_bound,
6483            authorization,
6484            context,
6485            snapshot_override,
6486            read,
6487        )
6488        .map(|(result, _)| result)
6489    }
6490
6491    #[allow(clippy::too_many_arguments)]
6492    pub fn with_authorized_scored_read_context_at_stamped<T, F>(
6493        &self,
6494        table_name: &str,
6495        principal: Option<&crate::auth::Principal>,
6496        catalog_bound: bool,
6497        authorization: Option<&ReadAuthorization>,
6498        context: Option<&crate::query::AiExecutionContext>,
6499        snapshot_override: Option<Snapshot>,
6500        mut read: F,
6501    ) -> Result<(T, AuthorizedReadStamp)>
6502    where
6503        F: FnMut(
6504            &Table,
6505            Snapshot,
6506            Option<&crate::security::CandidateAuthorization<'_>>,
6507            Option<&crate::auth::Principal>,
6508        ) -> Result<T>,
6509    {
6510        if principal.is_none() && self.principal.read().is_some() {
6511            self.refresh_principal()?;
6512        }
6513        const RETRIES: usize = 3;
6514        let handle = self.table(table_name)?;
6515        for attempt in 0..RETRIES {
6516            if let Some(context) = context {
6517                context.checkpoint()?;
6518            }
6519            crate::trace::QueryTrace::record(|trace| {
6520                trace.authorization_retries = attempt;
6521            });
6522            let (security, security_version, effective_principal) = {
6523                let catalog = self.catalog.read();
6524                (
6525                    catalog.security.clone(),
6526                    catalog.security_version,
6527                    self.principal_for_authorized_read(&catalog, principal, catalog_bound)?,
6528                )
6529            };
6530            if let Some(authorization) = authorization {
6531                for permission in &authorization.permissions {
6532                    self.require_for(effective_principal.as_ref(), permission)?;
6533                }
6534                self.require_columns_for(
6535                    table_name,
6536                    authorization.operation,
6537                    &authorization.columns,
6538                    effective_principal.as_ref(),
6539                )?;
6540            }
6541            let result = {
6542                let (table, snapshot, _snapshot_guard, _run_pins) =
6543                    self.scored_read_generation(&handle, context, snapshot_override)?;
6544                let candidate_authorization = if security.rls_enabled(table_name) {
6545                    Some(crate::security::CandidateAuthorization {
6546                        table: table_name,
6547                        security: &security,
6548                        principal: effective_principal
6549                            .as_ref()
6550                            .ok_or(MongrelError::AuthRequired)?,
6551                    })
6552                } else {
6553                    None
6554                };
6555                let stamp = AuthorizedReadStamp {
6556                    table_id: table.table_id(),
6557                    schema_id: table.schema().schema_id,
6558                    data_generation: table.data_generation(),
6559                    security_version,
6560                    snapshot,
6561                };
6562                let result = read(
6563                    table.as_ref(),
6564                    snapshot,
6565                    candidate_authorization.as_ref(),
6566                    effective_principal.as_ref(),
6567                )?;
6568                (result, stamp)
6569            };
6570            if let Some(context) = context {
6571                context.checkpoint()?;
6572            }
6573            if self.catalog.read().security_version == security_version {
6574                return Ok(result);
6575            }
6576            if attempt + 1 == RETRIES {
6577                return Err(MongrelError::Conflict(
6578                    "security policy changed during scored read".into(),
6579                ));
6580            }
6581        }
6582        Err(MongrelError::Conflict(
6583            "scored-read authorization retry loop exhausted".into(),
6584        ))
6585    }
6586
6587    fn scored_read_generation(
6588        &self,
6589        handle: &TableHandle,
6590        context: Option<&crate::query::AiExecutionContext>,
6591        snapshot_override: Option<Snapshot>,
6592    ) -> Result<(
6593        Arc<TableReadGeneration>,
6594        Snapshot,
6595        crate::retention::OwnedSnapshotGuard,
6596        RunPins,
6597    )> {
6598        let mut table = if let Some(context) = context {
6599            loop {
6600                context.checkpoint()?;
6601                let wait = context
6602                    .remaining_duration()
6603                    .unwrap_or(std::time::Duration::from_millis(5))
6604                    .min(std::time::Duration::from_millis(5));
6605                if let Some(table) = handle.try_lock_for(wait) {
6606                    break table;
6607                }
6608            }
6609        } else {
6610            handle.lock()
6611        };
6612        let (snapshot, snapshot_guard) = if let Some(snapshot) = snapshot_override {
6613            self.snapshot_at_owned(snapshot.epoch)?
6614        } else {
6615            let snapshot = table.snapshot();
6616            let guard = self.snapshots.register_owned(snapshot.epoch);
6617            (snapshot, guard)
6618        };
6619        let table_id = table.table_id();
6620        let run_keys: Vec<_> = table
6621            .active_run_ids()
6622            .map(|run_id| (table_id, run_id))
6623            .collect();
6624        let generation = handle
6625            .generation_metrics
6626            .activate(table.clone_read_generation()?);
6627        let run_pins = self.pin_runs(&run_keys);
6628        Ok((generation, snapshot, snapshot_guard, run_pins))
6629    }
6630
6631    fn pin_runs(&self, runs: &[(u64, u128)]) -> RunPins {
6632        let mut pins = self.backup_pins.lock();
6633        for run in runs {
6634            *pins.entry(*run).or_insert(0) += 1;
6635        }
6636        drop(pins);
6637        RunPins {
6638            pins: Arc::clone(&self.backup_pins),
6639            runs: runs.to_vec(),
6640        }
6641    }
6642
6643    /// Execute a native conjunctive read with the database principal's row
6644    /// policy, column grants, and masks applied. Raw [`Table`] methods remain
6645    /// policy-unaware; language bindings must use this boundary for reads.
6646    pub fn query_for_current_principal(
6647        &self,
6648        table_name: &str,
6649        query: &crate::query::Query,
6650        projection: Option<&[u16]>,
6651    ) -> Result<Vec<crate::memtable::Row>> {
6652        let condition_columns = crate::query::condition_columns(&query.conditions);
6653        let catalog_bound = self
6654            .principal
6655            .read()
6656            .as_ref()
6657            .is_some_and(|principal| principal.user_id != 0);
6658        self.with_authorized_read(
6659            table_name,
6660            None,
6661            catalog_bound,
6662            |table, snapshot, allowed, principal| {
6663                let allowed_columns = self.select_column_ids_for(table_name, principal)?;
6664                self.require_columns_for(
6665                    table_name,
6666                    crate::auth::ColumnOperation::Select,
6667                    &condition_columns,
6668                    principal,
6669                )?;
6670                if let Some(projection) = projection {
6671                    self.require_columns_for(
6672                        table_name,
6673                        crate::auth::ColumnOperation::Select,
6674                        projection,
6675                        principal,
6676                    )?;
6677                }
6678                let mut rows = table.query_at_with_allowed(query, snapshot, allowed)?;
6679                let projection =
6680                    projection.map(|columns| columns.iter().copied().collect::<HashSet<_>>());
6681                for row in &mut rows {
6682                    row.columns.retain(|column, _| {
6683                        allowed_columns.contains(column)
6684                            && projection
6685                                .as_ref()
6686                                .is_none_or(|projection| projection.contains(column))
6687                    });
6688                }
6689                self.secure_rows_for(table_name, rows, principal)
6690            },
6691        )
6692    }
6693
6694    /// Execute a secured native read with cooperative cancellation across
6695    /// authorization, candidate generation, materialization, masking, and
6696    /// projection.
6697    pub fn query_for_current_principal_controlled(
6698        &self,
6699        table_name: &str,
6700        query: &crate::query::Query,
6701        projection: Option<&[u16]>,
6702        control: &crate::ExecutionControl,
6703    ) -> Result<Vec<crate::memtable::Row>> {
6704        let catalog_bound = self
6705            .principal
6706            .read()
6707            .as_ref()
6708            .is_some_and(|principal| principal.user_id != 0);
6709        self.query_for_principal_controlled(
6710            table_name,
6711            query,
6712            projection,
6713            None,
6714            catalog_bound,
6715            control,
6716        )
6717    }
6718
6719    /// Execute a secured native read as an explicitly validated principal.
6720    ///
6721    /// Protocol adapters use this after resolving a session-bound identity.
6722    pub fn query_as_principal_controlled(
6723        &self,
6724        table_name: &str,
6725        query: &crate::query::Query,
6726        projection: Option<&[u16]>,
6727        principal: Option<&crate::auth::Principal>,
6728        control: &crate::ExecutionControl,
6729    ) -> Result<Vec<crate::memtable::Row>> {
6730        let catalog_bound = principal.is_some_and(|principal| principal.user_id != 0);
6731        self.query_for_principal_controlled(
6732            table_name,
6733            query,
6734            projection,
6735            principal,
6736            catalog_bound,
6737            control,
6738        )
6739    }
6740
6741    fn query_for_principal_controlled(
6742        &self,
6743        table_name: &str,
6744        query: &crate::query::Query,
6745        projection: Option<&[u16]>,
6746        principal: Option<&crate::auth::Principal>,
6747        catalog_bound: bool,
6748        control: &crate::ExecutionControl,
6749    ) -> Result<Vec<crate::memtable::Row>> {
6750        control.checkpoint()?;
6751        let context = crate::query::AiExecutionContext::with_control(
6752            control.clone(),
6753            usize::MAX,
6754            crate::query::MAX_FUSED_CANDIDATES,
6755        );
6756        let condition_columns = crate::query::condition_columns(&query.conditions);
6757        self.with_authorized_read_context(
6758            table_name,
6759            principal,
6760            catalog_bound,
6761            None,
6762            Some(&context),
6763            None,
6764            |table, snapshot, allowed, principal| {
6765                control.checkpoint()?;
6766                let allowed_columns = self.select_column_ids_for(table_name, principal)?;
6767                self.require_columns_for(
6768                    table_name,
6769                    crate::auth::ColumnOperation::Select,
6770                    &condition_columns,
6771                    principal,
6772                )?;
6773                if let Some(projection) = projection {
6774                    self.require_columns_for(
6775                        table_name,
6776                        crate::auth::ColumnOperation::Select,
6777                        projection,
6778                        principal,
6779                    )?;
6780                }
6781                let rows =
6782                    table.query_at_with_allowed_controlled(query, snapshot, allowed, control)?;
6783                let projection =
6784                    projection.map(|columns| columns.iter().copied().collect::<HashSet<_>>());
6785                let mut projected = Vec::with_capacity(rows.len());
6786                for (index, mut row) in rows.into_iter().enumerate() {
6787                    if index & 255 == 0 {
6788                        control.checkpoint()?;
6789                    }
6790                    row.columns.retain(|column, _| {
6791                        allowed_columns.contains(column)
6792                            && projection
6793                                .as_ref()
6794                                .is_none_or(|projection| projection.contains(column))
6795                    });
6796                    projected.push(row);
6797                }
6798                self.secure_rows_for_with_context(table_name, projected, principal, Some(&context))
6799            },
6800        )
6801    }
6802
6803    /// Reservoir aggregate with column grants, RLS, masks, and security-version
6804    /// retry applied at the database boundary.
6805    pub fn approx_aggregate_for_current_principal(
6806        &self,
6807        table_name: &str,
6808        conditions: &[crate::query::Condition],
6809        column: Option<u16>,
6810        agg: crate::engine::ApproxAgg,
6811        z: f64,
6812    ) -> Result<Option<crate::engine::ApproxResult>> {
6813        if !z.is_finite() || z <= 0.0 {
6814            return Err(MongrelError::InvalidArgument(
6815                "z must be finite and > 0".into(),
6816            ));
6817        }
6818        let mut columns = crate::query::condition_columns(conditions);
6819        columns.extend(column);
6820        columns.sort_unstable();
6821        columns.dedup();
6822        self.with_authorized_aggregate_table(
6823            table_name,
6824            &columns,
6825            None,
6826            true,
6827            true,
6828            |table, authorization, _, _| {
6829                table.approx_aggregate_with_candidate_authorization(
6830                    conditions,
6831                    column,
6832                    agg,
6833                    z,
6834                    authorization,
6835                )
6836            },
6837        )
6838    }
6839
6840    /// Incremental aggregate over an append-only table. Active RLS or masks are
6841    /// rejected because the table-global delta cache cannot safely represent a
6842    /// secured row universe.
6843    pub fn incremental_aggregate_for_current_principal(
6844        &self,
6845        table_name: &str,
6846        conditions: &[crate::query::Condition],
6847        column: Option<u16>,
6848        agg: crate::engine::NativeAgg,
6849    ) -> Result<crate::engine::IncrementalAggResult> {
6850        self.incremental_aggregate_for_principal(table_name, conditions, column, agg, None, true)
6851    }
6852
6853    /// Incremental aggregate using an explicit request principal. A
6854    /// catalog-bound principal is re-resolved on every retry so live grants,
6855    /// revocations, RLS, and masks cannot reuse a stale cache entry.
6856    pub fn incremental_aggregate_for_principal(
6857        &self,
6858        table_name: &str,
6859        conditions: &[crate::query::Condition],
6860        column: Option<u16>,
6861        agg: crate::engine::NativeAgg,
6862        principal: Option<&crate::auth::Principal>,
6863        catalog_bound: bool,
6864    ) -> Result<crate::engine::IncrementalAggResult> {
6865        let mut columns = crate::query::condition_columns(conditions);
6866        columns.extend(column);
6867        columns.sort_unstable();
6868        columns.dedup();
6869        self.with_authorized_aggregate_table(
6870            table_name,
6871            &columns,
6872            principal,
6873            catalog_bound,
6874            false,
6875            |table, _, principal, security_version| {
6876                let cache_key = incremental_aggregate_cache_key(
6877                    table_name,
6878                    conditions,
6879                    column,
6880                    agg,
6881                    principal,
6882                    security_version,
6883                );
6884                table.aggregate_incremental(cache_key, conditions, column, agg)
6885            },
6886        )
6887    }
6888
6889    /// Read one row with the database principal's row policy, column grants,
6890    /// and masks applied.
6891    pub fn get_for_current_principal(
6892        &self,
6893        table_name: &str,
6894        row_id: RowId,
6895    ) -> Result<Option<crate::memtable::Row>> {
6896        self.with_authorized_read(
6897            table_name,
6898            None,
6899            true,
6900            |table, snapshot, allowed, principal| {
6901                let allowed_columns = self.select_column_ids_for(table_name, principal)?;
6902                let Some(row) = table.get(row_id, snapshot) else {
6903                    return Ok(None);
6904                };
6905                if allowed.is_some_and(|allowed| !allowed.contains(&row.row_id)) {
6906                    return Ok(None);
6907                }
6908                let mut rows = self.secure_rows_for(table_name, vec![row], principal)?;
6909                if let Some(row) = rows.first_mut() {
6910                    row.columns
6911                        .retain(|column, _| allowed_columns.contains(column));
6912                }
6913                Ok(rows.pop())
6914            },
6915        )
6916    }
6917
6918    /// Run exact ANN reranking over only rows authorized for this database
6919    /// handle. The embedding column still requires normal column access.
6920    pub fn ann_rerank_for_current_principal(
6921        &self,
6922        table_name: &str,
6923        request: &crate::query::AnnRerankRequest,
6924    ) -> Result<Vec<crate::query::AnnRerankHit>> {
6925        self.with_authorized_scored_read_context_at(
6926            table_name,
6927            None,
6928            true,
6929            Some(&ReadAuthorization {
6930                operation: crate::auth::ColumnOperation::Select,
6931                columns: vec![request.column_id],
6932                permissions: Vec::new(),
6933            }),
6934            None,
6935            None,
6936            |table, snapshot, authorization, principal| {
6937                self.require_columns_for(
6938                    table_name,
6939                    crate::auth::ColumnOperation::Select,
6940                    &[request.column_id],
6941                    principal,
6942                )?;
6943                table.ann_rerank_at_with_candidate_authorization_on_generation(
6944                    request,
6945                    snapshot,
6946                    authorization,
6947                    None,
6948                )
6949            },
6950        )
6951    }
6952
6953    /// Run a hybrid scored search over only rows authorized for this database
6954    /// handle. Applies retriever column grants, RLS, and masks to the returned
6955    /// hits.
6956    pub fn search_for_current_principal(
6957        &self,
6958        table_name: &str,
6959        request: &crate::query::SearchRequest,
6960    ) -> Result<Vec<crate::query::SearchHit>> {
6961        let principal = self.principal_snapshot();
6962        self.search_for_principal_with_context(table_name, request, principal.as_ref(), None)
6963    }
6964
6965    /// Run a hybrid scored search as an explicitly validated principal.
6966    ///
6967    /// Protocol adapters use this after validating their session-bound
6968    /// identity. RLS, column grants, masks, cancellation, deadlines, and work
6969    /// limits remain enforced inside the storage authorization boundary.
6970    pub fn search_for_principal_with_context(
6971        &self,
6972        table_name: &str,
6973        request: &crate::query::SearchRequest,
6974        principal: Option<&crate::auth::Principal>,
6975        context: Option<&crate::query::AiExecutionContext>,
6976    ) -> Result<Vec<crate::query::SearchHit>> {
6977        let mut columns = crate::query::condition_columns(&request.must);
6978        for named in &request.retrievers {
6979            columns.push(named.retriever.column_id());
6980        }
6981        if let Some(crate::query::Rerank::ExactVector {
6982            embedding_column, ..
6983        }) = &request.rerank
6984        {
6985            columns.push(*embedding_column);
6986        }
6987        if let Some(proj) = &request.projection {
6988            columns.extend(proj);
6989        }
6990        columns.sort_unstable();
6991        columns.dedup();
6992        let catalog_bound = principal.is_some_and(|principal| principal.user_id != 0);
6993        self.with_authorized_scored_read_context_at(
6994            table_name,
6995            principal,
6996            catalog_bound,
6997            Some(&ReadAuthorization {
6998                operation: crate::auth::ColumnOperation::Select,
6999                columns: columns.clone(),
7000                permissions: Vec::new(),
7001            }),
7002            context,
7003            None,
7004            |table, snapshot, authorization, principal| {
7005                self.require_columns_for(
7006                    table_name,
7007                    crate::auth::ColumnOperation::Select,
7008                    &columns,
7009                    principal,
7010                )?;
7011                let hits = table.search_at_with_candidate_authorization_on_generation(
7012                    request,
7013                    snapshot,
7014                    authorization,
7015                    context,
7016                )?;
7017                let allowed_columns = self.select_column_ids_for(table_name, principal)?;
7018                let mut secured: Vec<crate::query::SearchHit> = Vec::with_capacity(hits.len());
7019                for mut hit in hits {
7020                    let row = crate::memtable::Row {
7021                        row_id: hit.row_id,
7022                        committed_epoch: crate::Epoch(0),
7023                        columns: hit
7024                            .cells
7025                            .iter()
7026                            .cloned()
7027                            .collect::<std::collections::HashMap<u16, crate::Value>>(),
7028                        deleted: false,
7029                        commit_ts: None,
7030                    };
7031                    let mut rows = self.secure_rows_for(table_name, vec![row], principal)?;
7032                    if let Some(mut row) = rows.pop() {
7033                        row.columns
7034                            .retain(|column, _| allowed_columns.contains(column));
7035                        hit.cells = row.columns.into_iter().collect::<Vec<_>>();
7036                        hit.cells.sort_by_key(|(column, _)| *column);
7037                        secured.push(hit);
7038                    }
7039                }
7040                Ok(secured)
7041            },
7042        )
7043    }
7044
7045    /// Embed `text` with the active semantic identity for `embedding_column` and
7046    /// run ANN retrieval, returning hits plus query provenance (P0.7).
7047    pub fn retrieve_text(
7048        &self,
7049        table: &str,
7050        embedding_column: u16,
7051        text: &str,
7052        search_options: crate::embedding::TextSearchOptions,
7053    ) -> Result<crate::embedding::TextRetrieveResult> {
7054        let principal = self.principal_snapshot();
7055        self.retrieve_text_for_principal(
7056            table,
7057            embedding_column,
7058            text,
7059            search_options,
7060            principal.as_ref(),
7061        )
7062    }
7063
7064    pub fn retrieve_text_for_principal(
7065        &self,
7066        table: &str,
7067        embedding_column: u16,
7068        text: &str,
7069        search_options: crate::embedding::TextSearchOptions,
7070        principal: Option<&crate::auth::Principal>,
7071    ) -> Result<crate::embedding::TextRetrieveResult> {
7072        if search_options.k == 0 {
7073            return Err(MongrelError::InvalidArgument(
7074                "retrieve_text k must be greater than zero".into(),
7075            ));
7076        }
7077        let catalog_bound = principal.is_some_and(|p| p.user_id != 0);
7078        let (semantic_identity, registry_generation, expected_dim) = {
7079            let handle = self.table(table)?;
7080            let mut g = handle.lock();
7081            g.ensure_indexes_complete()?;
7082            let schema = g.schema().clone();
7083            let column = schema
7084                .columns
7085                .iter()
7086                .find(|c| c.id == embedding_column)
7087                .ok_or_else(|| {
7088                    MongrelError::ColumnNotFound(format!(
7089                        "embedding column {embedding_column} not found on table {table}"
7090                    ))
7091                })?;
7092            let dim = match column.ty {
7093                crate::schema::TypeId::Embedding { dim } => dim,
7094                _ => {
7095                    return Err(MongrelError::InvalidArgument(format!(
7096                        "column {embedding_column} is not an embedding column"
7097                    )))
7098                }
7099            };
7100            if let Some(identity) = g
7101                .ann_index(embedding_column)
7102                .and_then(|a| a.semantic_identity().cloned())
7103            {
7104                let (generation, provider) = self
7105                    .embedding_providers
7106                    .resolve_by_semantic_identity(&identity)
7107                    .map_err(embedding_error)?;
7108                if provider.dimension() != dim {
7109                    return Err(embedding_error(
7110                        crate::embedding::EmbeddingError::DimensionMismatch {
7111                            expected: dim,
7112                            got: provider.dimension(),
7113                        },
7114                    ));
7115                }
7116                (identity, generation, dim)
7117            } else {
7118                let source = column.embedding_source.clone().ok_or_else(|| {
7119                    embedding_error(crate::embedding::EmbeddingError::NoActiveAnnIdentity(
7120                        embedding_column,
7121                    ))
7122                })?;
7123                let (generation, provider) = self
7124                    .embedding_providers
7125                    .resolve_with_generation(&source)
7126                    .map_err(embedding_error)?;
7127                let identity = provider.semantic_identity();
7128                if identity.dimension != dim {
7129                    return Err(embedding_error(
7130                        crate::embedding::EmbeddingError::DimensionMismatch {
7131                            expected: dim,
7132                            got: identity.dimension,
7133                        },
7134                    ));
7135                }
7136                (identity, generation, dim)
7137            }
7138        };
7139        let (_g, provider) = self
7140            .embedding_providers
7141            .resolve_by_semantic_identity(&semantic_identity)
7142            .map_err(embedding_error)?;
7143        let control = crate::ExecutionControl::new(None);
7144        let response = provider
7145            .embed(crate::embedding::EmbeddingRequest {
7146                texts: &[text],
7147                control: &control,
7148                trace_id: "retrieve-text",
7149            })
7150            .map_err(embedding_error)?;
7151        crate::embedding::validate_response(
7152            provider.as_ref(),
7153            1,
7154            expected_dim,
7155            crate::embedding::EmbeddingLimits::default(),
7156            &response.vectors,
7157        )
7158        .map_err(embedding_error)?;
7159        let query = response.vectors.into_iter().next().ok_or_else(|| {
7160            MongrelError::Other("embedding provider returned no query vector".into())
7161        })?;
7162        if provider.semantic_identity() != semantic_identity {
7163            return Err(embedding_error(
7164                crate::embedding::EmbeddingError::AnnSemanticIdentityMismatch {
7165                    column_id: embedding_column,
7166                    expected: semantic_identity.fingerprint_sha256(),
7167                    got: provider.semantic_identity().fingerprint_sha256(),
7168                },
7169            ));
7170        }
7171        let retriever = crate::query::Retriever::Ann {
7172            column_id: embedding_column,
7173            query,
7174            k: search_options.k,
7175        };
7176        let hits = self.with_authorized_scored_read_context_at(
7177            table,
7178            principal,
7179            catalog_bound,
7180            Some(&ReadAuthorization {
7181                operation: crate::auth::ColumnOperation::Select,
7182                columns: vec![embedding_column],
7183                permissions: Vec::new(),
7184            }),
7185            None,
7186            None,
7187            |table_guard, snapshot, authorization, principal| {
7188                self.require_columns_for(
7189                    table,
7190                    crate::auth::ColumnOperation::Select,
7191                    &[embedding_column],
7192                    principal,
7193                )?;
7194                if let Some(ann) = table_guard.ann_index(embedding_column) {
7195                    if let Some(active) = ann.semantic_identity() {
7196                        if active != &semantic_identity {
7197                            return Err(embedding_error(
7198                                crate::embedding::EmbeddingError::AnnSemanticIdentityMismatch {
7199                                    column_id: embedding_column,
7200                                    expected: semantic_identity.fingerprint_sha256(),
7201                                    got: active.fingerprint_sha256(),
7202                                },
7203                            ));
7204                        }
7205                    }
7206                }
7207                table_guard.retrieve_at_with_candidate_authorization_on_generation(
7208                    &retriever,
7209                    snapshot,
7210                    authorization,
7211                    None,
7212                )
7213            },
7214        )?;
7215        Ok(crate::embedding::TextRetrieveResult {
7216            hits,
7217            provenance: crate::embedding::TextRetrieveProvenance {
7218                semantic_identity,
7219                provider_registry_generation: registry_generation,
7220                query_source_fingerprint: Sha256::digest(text.as_bytes()).into(),
7221                embedding_column,
7222            },
7223        })
7224    }
7225
7226    /// Capture one table snapshot and the security version used to authorize it.
7227    /// The caller must validate the returned version before publishing results.
7228    pub fn authorized_read_snapshot(
7229        &self,
7230        table: &str,
7231        principal: Option<&crate::auth::Principal>,
7232    ) -> Result<AuthorizedReadSnapshot> {
7233        let (security, security_version, effective_principal) = {
7234            let catalog = self.catalog.read();
7235            (
7236                catalog.security.clone(),
7237                catalog.security_version,
7238                self.principal_for_authorized_read(&catalog, principal, false)?,
7239            )
7240        };
7241        let handle = self.table(table)?;
7242        let (table_snapshot, data_generation, allowed_row_ids) = {
7243            let table_handle = handle.lock();
7244            let table_snapshot = table_handle.snapshot();
7245            let data_generation = table_handle.data_generation();
7246            let allowed = self.allowed_row_ids_locked(
7247                table,
7248                &table_handle,
7249                table_snapshot,
7250                (&security, security_version),
7251                effective_principal.as_ref(),
7252                None,
7253            )?;
7254            (
7255                table_snapshot,
7256                data_generation,
7257                allowed.map(|allowed| (*allowed).clone()),
7258            )
7259        };
7260        Ok(AuthorizedReadSnapshot {
7261            table: table.to_string(),
7262            table_snapshot,
7263            data_generation,
7264            security_version,
7265            allowed_row_ids,
7266        })
7267    }
7268
7269    pub fn authorized_read_snapshot_valid(&self, snapshot: &AuthorizedReadSnapshot) -> bool {
7270        if self.catalog.read().security_version != snapshot.security_version {
7271            return false;
7272        }
7273        self.table(&snapshot.table)
7274            .ok()
7275            .is_some_and(|table| table.lock().data_generation() == snapshot.data_generation)
7276    }
7277
7278    pub fn rls_cache_stats(&self) -> RlsCacheStats {
7279        self.rls_cache.lock().stats()
7280    }
7281
7282    /// Read visible rows with column authorization, RLS, and masks applied.
7283    pub fn rows_for(
7284        &self,
7285        table: &str,
7286        principal: Option<&crate::auth::Principal>,
7287    ) -> Result<Vec<crate::memtable::Row>> {
7288        if principal.is_none() && self.principal.read().is_some() {
7289            self.refresh_principal()?;
7290        }
7291        let allowed = self.select_column_ids_for(table, principal)?;
7292        let handle = self.table(table)?;
7293        let rows = {
7294            let table = handle.lock();
7295            table.visible_rows(table.snapshot())?
7296        };
7297        let mut rows = self.secure_rows_for(table, rows, principal)?;
7298        for row in &mut rows {
7299            row.columns.retain(|column, _| allowed.contains(column));
7300        }
7301        Ok(rows)
7302    }
7303
7304    /// Historical rows use the current principal and security catalog against
7305    /// the row values visible at the requested snapshot.
7306    pub fn rows_at_epoch_for_current_principal(
7307        &self,
7308        table_name: &str,
7309        snapshot: Snapshot,
7310    ) -> Result<Vec<crate::memtable::Row>> {
7311        self.with_authorized_read_context(
7312            table_name,
7313            None,
7314            true,
7315            Some(&ReadAuthorization {
7316                operation: crate::auth::ColumnOperation::Select,
7317                columns: Vec::new(),
7318                permissions: Vec::new(),
7319            }),
7320            None,
7321            Some(snapshot),
7322            |table, snapshot, allowed, principal| {
7323                let allowed_columns = self.select_column_ids_for(table_name, principal)?;
7324                let mut rows = table.visible_rows(snapshot)?;
7325                if let Some(allowed) = allowed {
7326                    rows.retain(|row| allowed.contains(&row.row_id));
7327                }
7328                rows = self.secure_rows_for(table_name, rows, principal)?;
7329                for row in &mut rows {
7330                    row.columns
7331                        .retain(|column, _| allowed_columns.contains(column));
7332                }
7333                Ok(rows)
7334            },
7335        )
7336    }
7337
7338    /// Count rows visible to a principal without bypassing RLS.
7339    pub fn count_for(
7340        &self,
7341        table: &str,
7342        principal: Option<&crate::auth::Principal>,
7343    ) -> Result<u64> {
7344        if principal.is_none() && self.principal.read().is_some() {
7345            self.refresh_principal()?;
7346        }
7347        self.select_column_ids_for(table, principal)?;
7348        if self.security_active_for(table) {
7349            Ok(self.rows_for(table, principal)?.len() as u64)
7350        } else {
7351            Ok(self.table(table)?.lock().count())
7352        }
7353    }
7354
7355    /// Authorize and write one native-API row for an explicit principal.
7356    pub fn put_for(
7357        &self,
7358        table: &str,
7359        mut cells: Vec<(u16, crate::memtable::Value)>,
7360        principal: Option<&crate::auth::Principal>,
7361    ) -> Result<RowId> {
7362        let columns = cells.iter().map(|(column, _)| *column).collect::<Vec<_>>();
7363        self.require_columns_for(
7364            table,
7365            crate::auth::ColumnOperation::Insert,
7366            &columns,
7367            principal,
7368        )?;
7369        let handle = self.table(table)?;
7370        let mut table_handle = handle.lock();
7371        table_handle.fill_auto_inc(&mut cells)?;
7372        table_handle.apply_defaults(&mut cells)?;
7373        let mut row = crate::memtable::Row::new(RowId(0), self.epoch.visible());
7374        row.columns.extend(cells.iter().cloned());
7375        self.check_row_policy_for(
7376            table,
7377            crate::security::PolicyCommand::Insert,
7378            &row,
7379            true,
7380            principal,
7381        )?;
7382        table_handle.put(cells)
7383    }
7384
7385    pub fn check_row_policy_for(
7386        &self,
7387        table: &str,
7388        command: crate::security::PolicyCommand,
7389        row: &crate::memtable::Row,
7390        check_new: bool,
7391        principal: Option<&crate::auth::Principal>,
7392    ) -> Result<()> {
7393        let security = self.catalog.read().security.clone();
7394        if !security.rls_enabled(table) {
7395            return Ok(());
7396        }
7397        let cached = self.principal.read().clone();
7398        let principal = principal
7399            .or(cached.as_ref())
7400            .ok_or(MongrelError::AuthRequired)?;
7401        if security.row_allowed(table, command, row, principal, check_new) {
7402            return Ok(());
7403        }
7404        let required = match command {
7405            crate::security::PolicyCommand::Insert => crate::auth::Permission::Insert {
7406                table: table.to_string(),
7407            },
7408            crate::security::PolicyCommand::Update => crate::auth::Permission::Update {
7409                table: table.to_string(),
7410            },
7411            crate::security::PolicyCommand::Select => crate::auth::Permission::Select {
7412                table: table.to_string(),
7413            },
7414            crate::security::PolicyCommand::Delete | crate::security::PolicyCommand::All => {
7415                crate::auth::Permission::Delete {
7416                    table: table.to_string(),
7417                }
7418            }
7419        };
7420        Err(MongrelError::PermissionDenied {
7421            required,
7422            principal: principal.username.clone(),
7423        })
7424    }
7425
7426    /// Durably create or replace a materialized-view definition after its
7427    /// physical table has been populated.
7428    pub fn set_materialized_view(
7429        &self,
7430        definition: crate::catalog::MaterializedViewEntry,
7431    ) -> Result<()> {
7432        self.set_materialized_view_with_epoch(definition)
7433            .map(|_| ())
7434    }
7435
7436    /// Durably create or replace a materialized-view definition and return its epoch.
7437    pub fn set_materialized_view_with_epoch(
7438        &self,
7439        definition: crate::catalog::MaterializedViewEntry,
7440    ) -> Result<Epoch> {
7441        use crate::wal::DdlOp;
7442        use std::sync::atomic::Ordering;
7443
7444        let command = crate::catalog_cmds::CatalogCommand::CreateMaterializedView {
7445            definition: definition.clone(),
7446        };
7447        self.require(&crate::catalog_cmds::required_permission(&command))?;
7448        if self.poisoned.load(Ordering::Relaxed) {
7449            return Err(MongrelError::Other(
7450                "database poisoned by fsync error".into(),
7451            ));
7452        }
7453        if definition.name.is_empty() || definition.query.trim().is_empty() {
7454            return Err(MongrelError::InvalidArgument(
7455                "materialized view name and query must not be empty".into(),
7456            ));
7457        }
7458        // S1A-004: admit the materialized-view replacement as one core
7459        // operation.
7460        let _operation = self.admit_operation()?;
7461        let _ddl = self.ddl_lock.lock();
7462        let _security_write = self.security_write()?;
7463        self.require(&crate::catalog_cmds::required_permission(&command))?;
7464        // S1F-001: the backing-table check validates pure against the current
7465        // catalog first so it surfaces before an epoch is consumed, exactly
7466        // like the legacy pre-bump lookup.
7467        crate::catalog_cmds::apply(&self.catalog.read(), &command)?;
7468        let table_id = self
7469            .catalog
7470            .read()
7471            .live(&definition.name)
7472            .ok_or_else(|| {
7473                MongrelError::NotFound(format!(
7474                    "materialized view table {:?} not found",
7475                    definition.name
7476                ))
7477            })?
7478            .table_id;
7479        let definition_json = DdlOp::encode_materialized_view(&definition)?;
7480        let _commit = self.commit_lock.lock();
7481        let epoch = self.epoch.bump_assigned();
7482        let mut epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
7483        let txn_id = self.alloc_txn_id()?;
7484        let mut next_catalog = self.catalog.read().clone();
7485        self.apply_catalog_command_to(&mut next_catalog, command)?;
7486        next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
7487        let commit_seq = {
7488            let mut wal = self.shared_wal.lock();
7489            let append: Result<u64> = (|| {
7490                wal.append(
7491                    txn_id,
7492                    table_id,
7493                    crate::wal::Op::Ddl(DdlOp::SetMaterializedView {
7494                        name: definition.name.clone(),
7495                        definition_json,
7496                    }),
7497                )?;
7498                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
7499                wal.append_commit(txn_id, epoch, &[])
7500            })();
7501            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
7502        };
7503        let receipt = self.await_durable_commit(txn_id, commit_seq, epoch)?;
7504
7505        let checkpoint = self.checkpoint_catalog_after_durable(next_catalog);
7506        self.finish_durable_publish(epoch, &mut epoch_guard, &receipt, checkpoint)?;
7507        Ok(epoch)
7508    }
7509
7510    /// The filesystem root this database was opened/created at.
7511    pub fn root(&self) -> &Path {
7512        self.durable_root.canonical_path()
7513    }
7514
7515    /// Open a descriptor-pinned view of this database root for durable
7516    /// extension state such as server idempotency receipts.
7517    pub fn durable_root(&self) -> Arc<crate::durable_file::DurableRoot> {
7518        Arc::clone(&self.durable_root)
7519    }
7520
7521    /// Domain-separated authentication key for server idempotency state.
7522    /// Encrypted databases derive it from the in-memory KEK. Plain databases
7523    /// return `None`; their server persists a random key under the pinned root.
7524    pub fn derive_server_idempotency_key(&self) -> Option<zeroize::Zeroizing<[u8; 32]>> {
7525        self.kek
7526            .as_deref()
7527            .map(|kek| kek.derive_subkey(b"mongreldb/server/idempotency/v1"))
7528    }
7529
7530    pub fn is_read_only_replica(&self) -> bool {
7531        self.read_only
7532    }
7533
7534    /// Reject reads whose backing state may require WAL recovery after a
7535    /// post-commit publication failure. Ordinary table/catalog state is made
7536    /// coherent before poison; file-backed external modules use this gate.
7537    pub fn ensure_consistent_read(&self) -> Result<()> {
7538        self.ensure_owner_process()?;
7539        if self.poisoned.load(Ordering::Relaxed) {
7540            return Err(MongrelError::Other(
7541                "database poisoned by post-commit failure; reopen required".into(),
7542            ));
7543        }
7544        Ok(())
7545    }
7546
7547    pub fn set_replication_wal_retention_segments(&self, segments: usize) {
7548        self.replication_wal_retention_segments
7549            .store(segments, std::sync::atomic::Ordering::Relaxed);
7550    }
7551
7552    /// Capture a consistent bootstrap image. DDL, transaction spill/publish,
7553    /// direct table commits, compaction, and WAL append are quiesced while the
7554    /// file image is read. WAL records newer than manifests remain sufficient
7555    /// for recovery, so no flush or compaction is required.
7556    pub fn replication_snapshot(&self) -> Result<crate::replication::ReplicationSnapshot> {
7557        let admin = crate::auth::Permission::Admin;
7558        self.require(&admin)?;
7559        // S1A-004: admit the bootstrap capture as one core operation.
7560        let _operation = self.admit_operation()?;
7561        let operation_principal = self.principal_snapshot();
7562        let _barrier = self.replication_barrier.write();
7563        let _ddl = self.ddl_lock.lock();
7564        let _security = self.security_coordinator.gate.read();
7565        self.require_exact_principal_current(operation_principal.as_ref(), &admin)?;
7566        let mut handles: Vec<_> = self
7567            .tables
7568            .read()
7569            .iter()
7570            .map(|(id, handle)| (*id, handle.clone()))
7571            .collect();
7572        handles.sort_by_key(|(id, _)| *id);
7573        let _table_guards: Vec<_> = handles.iter().map(|(_, handle)| handle.lock()).collect();
7574        let _commit = self.commit_lock.lock();
7575        let mut wal = self.shared_wal.lock();
7576        wal.group_sync()?;
7577        let io_root = self.durable_root.io_path()?;
7578        let wal_dek = crate::encryption::wal_dek_for(self.kek.as_deref());
7579        let records = crate::wal::SharedWal::replay_with_dek(&io_root, wal_dek.as_ref())?;
7580        let epoch = records
7581            .iter()
7582            .filter_map(|record| match &record.op {
7583                crate::wal::Op::TxnCommit { epoch, .. } => Some(*epoch),
7584                _ => None,
7585            })
7586            .max()
7587            .unwrap_or(0)
7588            .max(self.epoch.committed().0);
7589        let files = crate::replication::capture_files(&io_root)?;
7590        let source_id = crate::replication::replication_identity_durable(&self.durable_root)?;
7591        drop(wal);
7592        Ok(crate::replication::ReplicationSnapshot::new(
7593            source_id, epoch, files,
7594        ))
7595    }
7596
7597    /// Create an online, directly-openable backup at `destination`.
7598    ///
7599    /// The short boundary phase quiesces commits/DDL, syncs the WAL, copies
7600    /// mutable metadata, and pins the exact immutable runs named by the copied
7601    /// manifests. Writers resume while those runs stream into a sibling staging
7602    /// directory. A checksummed backup manifest is written last, then the stage
7603    /// is atomically renamed into place.
7604    pub fn hot_backup(&self, destination: impl AsRef<Path>) -> Result<crate::backup::BackupReport> {
7605        let control = crate::ExecutionControl::new(None);
7606        self.hot_backup_controlled(destination, &control, || true)
7607    }
7608
7609    pub(crate) fn hot_backup_to_durable_child(
7610        &self,
7611        parent: &crate::durable_file::DurableRoot,
7612        child: &Path,
7613        control: &crate::ExecutionControl,
7614    ) -> Result<crate::backup::BackupReport> {
7615        let mut components = child.components();
7616        if !matches!(components.next(), Some(std::path::Component::Normal(_)))
7617            || components.next().is_some()
7618        {
7619            return Err(MongrelError::InvalidArgument(
7620                "durable backup child must be one relative path component".into(),
7621            ));
7622        }
7623        let destination_name = child.file_name().ok_or_else(|| {
7624            MongrelError::InvalidArgument("durable backup child has no filename".into())
7625        })?;
7626        let source_root = self.durable_root.io_path()?;
7627        let prepared = prepare_backup_destination_in(&source_root, parent, destination_name)?;
7628        self.hot_backup_prepared(prepared, control, || true)
7629    }
7630
7631    /// Build a backup cooperatively, then invoke `before_publish` immediately
7632    /// before the staging directory is atomically renamed into place.
7633    #[doc(hidden)]
7634    pub fn hot_backup_controlled<F>(
7635        &self,
7636        destination: impl AsRef<Path>,
7637        control: &crate::ExecutionControl,
7638        before_publish: F,
7639    ) -> Result<crate::backup::BackupReport>
7640    where
7641        F: FnOnce() -> bool,
7642    {
7643        let source_root = self.durable_root.io_path()?;
7644        let prepared = prepare_backup_destination(&source_root, destination.as_ref())?;
7645        self.hot_backup_prepared(prepared, control, before_publish)
7646    }
7647
7648    fn hot_backup_prepared<F>(
7649        &self,
7650        mut prepared: PreparedBackupDestination,
7651        control: &crate::ExecutionControl,
7652        before_publish: F,
7653    ) -> Result<crate::backup::BackupReport>
7654    where
7655        F: FnOnce() -> bool,
7656    {
7657        let admin = crate::auth::Permission::Admin;
7658        self.require(&admin)?;
7659        // S1A-004: admit the backup as one core operation; `shutdown()` waits
7660        // for it to drain instead of closing the core mid-copy.
7661        let _operation = self.admit_operation()?;
7662        let operation_principal = self.principal_snapshot();
7663        control.checkpoint()?;
7664        let destination = prepared.destination_path.clone();
7665        let mut before_publish = Some(before_publish);
7666
7667        let outcome = (|| {
7668            control.checkpoint()?;
7669            let barrier = self.replication_barrier.write();
7670            let ddl = self.ddl_lock.lock();
7671            let security = self.security_coordinator.gate.read();
7672            self.require_exact_principal_current(operation_principal.as_ref(), &admin)?;
7673            let mut handles: Vec<_> = self
7674                .tables
7675                .read()
7676                .iter()
7677                .map(|(id, handle)| (*id, handle.clone()))
7678                .collect();
7679            handles.sort_by_key(|(id, _)| *id);
7680            let table_guards: Vec<_> = handles.iter().map(|(_, handle)| handle.lock()).collect();
7681            let commit = self.commit_lock.lock();
7682            let mut wal = self.shared_wal.lock();
7683            wal.group_sync()?;
7684            let epoch = self.epoch.committed().0;
7685            let boundary_unix_nanos = current_unix_nanos();
7686            let source_root = self.durable_root.io_path()?;
7687
7688            let pin_nonce = std::time::SystemTime::now()
7689                .duration_since(std::time::UNIX_EPOCH)
7690                .unwrap_or_default()
7691                .as_nanos();
7692            let file_pin_root = source_root
7693                .join(META_DIR)
7694                .join("backup-pins")
7695                .join(format!("{}-{pin_nonce}", std::process::id()));
7696            std::fs::create_dir_all(&file_pin_root)?;
7697            let _file_pins = BackupFilePins {
7698                root: file_pin_root.clone(),
7699            };
7700            let mut run_files = Vec::new();
7701            for (index, (table_id, _)) in handles.iter().enumerate() {
7702                if index % 256 == 0 {
7703                    control.checkpoint()?;
7704                }
7705                let table = &table_guards[index];
7706                for (run_index, run) in table.run_refs().iter().enumerate() {
7707                    if run_index % 256 == 0 {
7708                        control.checkpoint()?;
7709                    }
7710                    let source = table.run_path(run.run_id as u64);
7711                    let relative = Path::new(TABLES_DIR)
7712                        .join(table_id.to_string())
7713                        .join(crate::engine::RUNS_DIR)
7714                        .join(format!("r-{}.sr", run.run_id));
7715                    let pinned = file_pin_root.join(format!("{table_id}-{}.sr", run.run_id));
7716                    if std::fs::hard_link(&source, &pinned).is_err() {
7717                        crate::backup::copy_file_synced(&source, &pinned)?;
7718                    }
7719                    run_files.push(((*table_id, run.run_id), pinned, relative));
7720                }
7721            }
7722            crate::durable_file::sync_directory(&file_pin_root)?;
7723            let run_keys: Vec<_> = run_files.iter().map(|(key, _, _)| *key).collect();
7724            {
7725                let mut pins = self.backup_pins.lock();
7726                for key in &run_keys {
7727                    *pins.entry(*key).or_insert(0) += 1;
7728                }
7729            }
7730            let _run_pins = RunPins {
7731                pins: Arc::clone(&self.backup_pins),
7732                runs: run_keys,
7733            };
7734            // S1C-004: mirror the backup boundary into every table's unified
7735            // pin registry (PinSource::BackupPitr covers PITR base captures,
7736            // which route through this same path). Version GC must not
7737            // reclaim the boundary versions while their runs stream into the
7738            // stage, and diagnostics must expose the pin. The guards drop
7739            // with `_run_pins` when the backup finishes or fails.
7740            let _registry_pins: Vec<crate::retention::PinGuard> = table_guards
7741                .iter()
7742                .map(|table| {
7743                    table
7744                        .pin_registry()
7745                        .pin(crate::retention::PinSource::BackupPitr, Epoch(epoch))
7746                })
7747                .collect();
7748            let deferred: HashSet<_> = run_files
7749                .iter()
7750                .map(|(_, _, relative)| relative.clone())
7751                .collect();
7752            let mut copied = Vec::new();
7753            copy_backup_boundary(
7754                &source_root,
7755                prepared.stage.as_deref().ok_or_else(|| {
7756                    MongrelError::Other("backup staging root was already released".into())
7757                })?,
7758                &deferred,
7759                &mut copied,
7760                Some(control),
7761            )?;
7762
7763            drop(wal);
7764            drop(commit);
7765            drop(table_guards);
7766            drop(security);
7767            drop(ddl);
7768            drop(barrier);
7769
7770            if let Some(hook) = self.backup_hook.lock().as_ref() {
7771                hook();
7772            }
7773            for (index, (_, source, relative)) in run_files.into_iter().enumerate() {
7774                if index % 256 == 0 {
7775                    control.checkpoint()?;
7776                }
7777                let mut source = crate::durable_file::open_regular_nofollow(&source)?;
7778                prepared
7779                    .stage
7780                    .as_deref()
7781                    .ok_or_else(|| {
7782                        MongrelError::Other("backup staging root was already released".into())
7783                    })?
7784                    .copy_new_from(&relative, &mut source)?;
7785                copied.push(relative);
7786            }
7787
7788            let manifest = crate::backup::BackupManifest::create_controlled_durable(
7789                prepared.stage.as_deref().ok_or_else(|| {
7790                    MongrelError::Other("backup staging root was already released".into())
7791                })?,
7792                epoch,
7793                &copied,
7794                control,
7795            )?;
7796            manifest.write_to_durable(prepared.stage.as_deref().ok_or_else(|| {
7797                MongrelError::Other("backup staging root was already released".into())
7798            })?)?;
7799            control.checkpoint()?;
7800            let publish = before_publish.take().ok_or_else(|| {
7801                MongrelError::Other("backup publication callback already consumed".into())
7802            })?;
7803            if !publish() {
7804                return Err(MongrelError::Cancelled);
7805            }
7806            let final_security = self.security_coordinator.gate.read();
7807            self.require_exact_principal_current(operation_principal.as_ref(), &admin)?;
7808            // Windows pins directories without delete sharing. Release the
7809            // stage handle before renaming that directory, while the parent
7810            // remains descriptor-pinned for the no-replace publication.
7811            drop(prepared.stage.take().ok_or_else(|| {
7812                MongrelError::Other("backup staging root was already released".into())
7813            })?);
7814            let published = std::cell::Cell::new(false);
7815            if let Err(error) = prepared.parent.rename_directory_new_with_after(
7816                Path::new(&prepared.stage_name),
7817                &prepared.parent,
7818                Path::new(&prepared.destination_name),
7819                || published.set(true),
7820            ) {
7821                if published.get() {
7822                    return Err(MongrelError::CommitOutcomeUnknown {
7823                        epoch,
7824                        message: format!("backup publication was not durable: {error}"),
7825                    });
7826                }
7827                return Err(error.into());
7828            }
7829            drop(final_security);
7830            Ok(crate::backup::BackupReport {
7831                destination,
7832                epoch,
7833                boundary_unix_nanos,
7834                files: manifest.files.len(),
7835                bytes: manifest.total_bytes(),
7836            })
7837        })();
7838
7839        if outcome.is_err() {
7840            drop(prepared.stage.take());
7841            let _ = prepared
7842                .parent
7843                .remove_directory_all(Path::new(&prepared.stage_name));
7844        }
7845        outcome
7846    }
7847
7848    /// Return complete committed transactions after `since_epoch`. A gap or a
7849    /// transaction backed by a spilled run requires a fresh bootstrap image.
7850    pub fn replication_batch_since(
7851        &self,
7852        since_epoch: u64,
7853    ) -> Result<crate::replication::ReplicationBatch> {
7854        use crate::wal::Op;
7855
7856        let admin = crate::auth::Permission::Admin;
7857        self.require(&admin)?;
7858        // S1A-004: admit the batch extraction as one core operation.
7859        let _operation = self.admit_operation()?;
7860        let operation_principal = self.principal_snapshot();
7861
7862        let mut wal = self.shared_wal.lock();
7863        wal.group_sync()?;
7864        let wal_dek = crate::encryption::wal_dek_for(self.kek.as_deref());
7865        let records = crate::wal::SharedWal::replay_with_dek(&self.root, wal_dek.as_ref())?;
7866        drop(wal);
7867
7868        let commits: HashMap<u64, u64> = records
7869            .iter()
7870            .filter_map(|record| match &record.op {
7871                Op::TxnCommit { epoch, .. } => Some((record.txn_id, *epoch)),
7872                _ => None,
7873            })
7874            .collect();
7875        let earliest_epoch = commits.values().copied().min();
7876        let current_epoch = commits
7877            .values()
7878            .copied()
7879            .max()
7880            .unwrap_or(0)
7881            .max(self.epoch.committed().0);
7882        let selected: HashSet<u64> = commits
7883            .iter()
7884            .filter_map(|(txn_id, epoch)| (*epoch > since_epoch).then_some(*txn_id))
7885            .collect();
7886        let retention_gap = since_epoch < current_epoch
7887            && since_epoch < crate::replication::replication_wal_floor(&self.root)?;
7888        let spilled = records.iter().any(|record| {
7889            selected.contains(&record.txn_id)
7890                && matches!(
7891                    &record.op,
7892                    Op::TxnCommit { added_runs, .. } if !added_runs.is_empty()
7893                )
7894        });
7895        let records = records
7896            .into_iter()
7897            .filter(|record| record.txn_id != crate::wal::SYSTEM_TXN_ID)
7898            .filter(|record| selected.contains(&record.txn_id))
7899            .collect();
7900        let source_id = crate::replication::replication_identity_durable(&self.durable_root)?;
7901        let batch = crate::replication::ReplicationBatch::complete_for_source(
7902            source_id,
7903            since_epoch,
7904            current_epoch,
7905            earliest_epoch,
7906            retention_gap,
7907            spilled,
7908            records,
7909        )?;
7910        if let Some(hook) = self.replication_hook.lock().as_ref() {
7911            hook();
7912        }
7913        let _security = self.security_coordinator.gate.read();
7914        self.require_exact_principal_current(operation_principal.as_ref(), &admin)?;
7915        Ok(batch)
7916    }
7917
7918    /// Durably append a leader batch to a follower's local WAL and checkpoint
7919    /// its catalog metadata. Security changes apply to this live handle before
7920    /// success returns. The caller must reopen to mount new table state.
7921    pub fn append_replication_batch(
7922        &self,
7923        batch: &crate::replication::ReplicationBatch,
7924    ) -> Result<u64> {
7925        use crate::wal::Op;
7926
7927        if !self.read_only {
7928            return Err(MongrelError::InvalidArgument(
7929                "replication batches may only target a marked replica".into(),
7930            ));
7931        }
7932        // S1A-004: admit the follow-apply as one core operation.
7933        let _operation = self.admit_operation()?;
7934        let current = crate::replication::replica_epoch(&self.root)?;
7935        if batch.is_source_bound() {
7936            let source_id = crate::replication::replica_source_id_durable(&self.durable_root)?;
7937            if batch.source_id != source_id {
7938                return Err(MongrelError::Conflict(
7939                    "replication batch source does not match follower binding".into(),
7940                ));
7941            }
7942        }
7943        if batch.requires_snapshot {
7944            return Err(MongrelError::Conflict(
7945                "replication snapshot required for this batch".into(),
7946            ));
7947        }
7948        batch.validate_proof()?;
7949        if batch.from_epoch != current {
7950            if batch.from_epoch < current && batch.current_epoch == current {
7951                let wal_dek = crate::encryption::wal_dek_for(self.kek.as_deref());
7952                let _wal = self.shared_wal.lock();
7953                let existing: HashSet<(u64, u64)> =
7954                    crate::wal::SharedWal::replay_with_dek(&self.root, wal_dek.as_ref())?
7955                        .into_iter()
7956                        .filter_map(|record| match record.op {
7957                            Op::TxnCommit { epoch, .. } => Some((record.txn_id, epoch)),
7958                            _ => None,
7959                        })
7960                        .collect();
7961                let already_applied = batch.records.iter().all(|record| match &record.op {
7962                    Op::TxnCommit { epoch, .. } => existing.contains(&(record.txn_id, *epoch)),
7963                    _ => true,
7964                });
7965                if already_applied {
7966                    return Ok(current);
7967                }
7968            }
7969            return Err(MongrelError::Conflict(format!(
7970                "replication batch starts at epoch {}, follower is at epoch {current}",
7971                batch.from_epoch
7972            )));
7973        }
7974        if batch.current_epoch < current {
7975            return Err(MongrelError::InvalidArgument(format!(
7976                "replication batch current epoch {} precedes follower epoch {current}",
7977                batch.current_epoch
7978            )));
7979        }
7980        let records = &batch.records;
7981        let mut commits = HashMap::new();
7982        let mut commit_epochs = HashSet::new();
7983        let mut commit_timestamps = HashMap::new();
7984        for record in records {
7985            match &record.op {
7986                Op::TxnCommit { epoch, added_runs } => {
7987                    if !added_runs.is_empty() {
7988                        return Err(MongrelError::Conflict(
7989                            "replication snapshot required for spilled-run transaction".into(),
7990                        ));
7991                    }
7992                    if commits.insert(record.txn_id, *epoch).is_some() {
7993                        return Err(MongrelError::InvalidArgument(format!(
7994                            "duplicate commit for replication transaction {}",
7995                            record.txn_id
7996                        )));
7997                    }
7998                    if *epoch <= current || *epoch > batch.current_epoch {
7999                        return Err(MongrelError::InvalidArgument(format!(
8000                            "replication commit epoch {epoch} is outside ({current}, {}]",
8001                            batch.current_epoch
8002                        )));
8003                    }
8004                    if !commit_epochs.insert(*epoch) {
8005                        return Err(MongrelError::InvalidArgument(format!(
8006                            "duplicate replication commit epoch {epoch}"
8007                        )));
8008                    }
8009                }
8010                Op::CommitTimestamp { unix_nanos } => {
8011                    commit_timestamps.insert(record.txn_id, *unix_nanos);
8012                }
8013                _ => {}
8014            }
8015        }
8016        for record in records {
8017            if record.txn_id == crate::wal::SYSTEM_TXN_ID
8018                || matches!(&record.op, Op::TxnAbort | Op::Flush { .. })
8019            {
8020                return Err(MongrelError::InvalidArgument(
8021                    "replication batch contains a non-committed record".into(),
8022                ));
8023            }
8024            if !commits.contains_key(&record.txn_id) {
8025                return Err(MongrelError::InvalidArgument(format!(
8026                    "incomplete replication transaction {}",
8027                    record.txn_id
8028                )));
8029            }
8030        }
8031        let target_epoch = commits
8032            .values()
8033            .copied()
8034            .filter(|epoch| *epoch > current)
8035            .max()
8036            .unwrap_or(current);
8037        if target_epoch != batch.current_epoch {
8038            return Err(MongrelError::InvalidArgument(format!(
8039                "replication batch ends at epoch {target_epoch}, expected {}",
8040                batch.current_epoch
8041            )));
8042        }
8043        let mut selected: HashSet<u64> = commits
8044            .iter()
8045            .filter_map(|(txn_id, epoch)| (*epoch > current).then_some(*txn_id))
8046            .collect();
8047        if selected.is_empty() {
8048            return Ok(current);
8049        }
8050        let mut wal = self.shared_wal.lock();
8051        wal.group_sync()?;
8052        let wal_dek = crate::encryption::wal_dek_for(self.kek.as_deref());
8053        let existing: HashSet<(u64, u64)> =
8054            crate::wal::SharedWal::replay_with_dek(&self.root, wal_dek.as_ref())?
8055                .into_iter()
8056                .filter_map(|record| match record.op {
8057                    Op::TxnCommit { epoch, .. } => Some((record.txn_id, epoch)),
8058                    _ => None,
8059                })
8060                .collect();
8061        selected.retain(|txn_id| {
8062            commits
8063                .get(txn_id)
8064                .is_some_and(|epoch| !existing.contains(&(*txn_id, *epoch)))
8065        });
8066        for record in records {
8067            if !selected.contains(&record.txn_id) {
8068                continue;
8069            }
8070            match &record.op {
8071                Op::TxnCommit { epoch, added_runs } => {
8072                    let timestamp = commit_timestamps
8073                        .get(&record.txn_id)
8074                        .copied()
8075                        .unwrap_or_else(current_unix_nanos);
8076                    wal.append_commit_at(record.txn_id, Epoch(*epoch), added_runs, timestamp)?;
8077                }
8078                Op::TxnAbort | Op::Flush { .. } | Op::CommitTimestamp { .. } => {}
8079                op => {
8080                    wal.append(record.txn_id, 0, op.clone())?;
8081                }
8082            }
8083        }
8084        if !selected.is_empty() {
8085            wal.group_sync()?;
8086        }
8087        drop(wal);
8088
8089        // Auth mode is selected before `finish_open` replays the WAL. Make the
8090        // catalog transition durable and publish security state to this live
8091        // handle before reporting success.
8092        let mut recovered_catalog = self.catalog.read().clone();
8093        if let Err(error) = recover_ddl_from_wal(
8094            &self.root,
8095            Some(&self.durable_root),
8096            &mut recovered_catalog,
8097            self.meta_dek.as_ref(),
8098            wal_dek.as_ref(),
8099            true,
8100            None,
8101        ) {
8102            return Err(MongrelError::DurableCommit {
8103                epoch: target_epoch,
8104                message: format!(
8105                    "replication WAL is durable but catalog checkpoint failed: {error}"
8106                ),
8107            });
8108        }
8109        let _security = self.security_coordinator.gate.write();
8110        let old_security_version = self.catalog.read().security_version;
8111        let security_changed = old_security_version != recovered_catalog.security_version
8112            || self.catalog.read().require_auth != recovered_catalog.require_auth;
8113        let require_auth = recovered_catalog.require_auth;
8114        let principal = if security_changed {
8115            None
8116        } else {
8117            self.principal.read().as_ref().and_then(|principal| {
8118                Self::resolve_bound_principal_from_catalog(&recovered_catalog, principal)
8119            })
8120        };
8121        if require_auth {
8122            self.auth_state.set_require_auth(true);
8123        }
8124        self.auth_state.set_principal(principal.clone());
8125        *self.principal.write() = principal;
8126        let security_version = recovered_catalog.security_version;
8127        *self.catalog.write() = recovered_catalog;
8128        self.security_coordinator
8129            .version
8130            .store(security_version, Ordering::Release);
8131        if !require_auth {
8132            self.auth_state.set_require_auth(false);
8133        }
8134        if let Err(error) =
8135            crate::replication::reconcile_replica_epoch_durable(&self.durable_root, target_epoch)
8136        {
8137            return Err(MongrelError::DurableCommit {
8138                epoch: target_epoch,
8139                message: format!(
8140                    "replication WAL and catalog are durable but follower watermark failed: {error}"
8141                ),
8142            });
8143        }
8144        Ok(target_epoch)
8145    }
8146
8147    /// Resolve a table name → id (live tables only). pub(crate) so the
8148    /// transaction layer can stage by name.
8149    pub fn table_id(&self, name: &str) -> Result<u64> {
8150        let cat = self.catalog.read();
8151        cat.live(name)
8152            .map(|e| e.table_id)
8153            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))
8154    }
8155
8156    /// Return the stable table id and current schema generation from one
8157    /// catalog snapshot. Callers can bind retries to this identity so a table
8158    /// dropped and recreated under the same name is never mistaken for the
8159    /// original resource.
8160    pub fn table_identity(&self, name: &str) -> Result<(u64, u64)> {
8161        let catalog = self.catalog.read();
8162        catalog
8163            .live(name)
8164            .map(|entry| (entry.table_id, entry.schema.schema_id))
8165            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))
8166    }
8167
8168    pub(crate) fn building_table_id(&self, name: &str) -> Result<u64> {
8169        self.catalog
8170            .read()
8171            .building(name)
8172            .map(|entry| entry.table_id)
8173            .ok_or_else(|| MongrelError::NotFound(format!("building table {name:?} not found")))
8174    }
8175
8176    pub fn procedures(&self) -> Vec<StoredProcedure> {
8177        self.catalog
8178            .read()
8179            .procedures
8180            .iter()
8181            .map(|p| p.procedure.clone())
8182            .collect()
8183    }
8184
8185    pub fn procedure(&self, name: &str) -> Option<StoredProcedure> {
8186        self.catalog
8187            .read()
8188            .procedures
8189            .iter()
8190            .find(|p| p.procedure.name == name)
8191            .map(|p| p.procedure.clone())
8192    }
8193
8194    pub fn create_procedure(&self, procedure: StoredProcedure) -> Result<StoredProcedure> {
8195        self.create_procedure_inner(procedure, None)
8196    }
8197
8198    pub fn create_procedure_controlled<F>(
8199        &self,
8200        procedure: StoredProcedure,
8201        mut before_publish: F,
8202    ) -> Result<StoredProcedure>
8203    where
8204        F: FnMut() -> Result<()>,
8205    {
8206        self.create_procedure_inner(procedure, Some(&mut before_publish))
8207    }
8208
8209    fn create_procedure_inner(
8210        &self,
8211        mut procedure: StoredProcedure,
8212        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
8213    ) -> Result<StoredProcedure> {
8214        let command = crate::catalog_cmds::CatalogCommand::CreateProcedure {
8215            procedure: procedure.clone(),
8216        };
8217        self.require(&crate::catalog_cmds::required_permission(&command))?;
8218        let _g = self.ddl_lock.lock();
8219        let _security_write = self.security_write()?;
8220        self.require(&crate::catalog_cmds::required_permission(&command))?;
8221        procedure.validate()?;
8222        self.validate_procedure_references(&procedure)?;
8223        // S1F-001: validation runs pure against the current catalog first so
8224        // failures (duplicate name, unknown table) surface before an epoch is
8225        // consumed, exactly like the legacy pre-bump checks.
8226        crate::catalog_cmds::apply(&self.catalog.read(), &command)?;
8227        let commit_lock = Arc::clone(&self.commit_lock);
8228        let _c = commit_lock.lock();
8229        let epoch = self.epoch.bump_assigned();
8230        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
8231        procedure.created_epoch = epoch.0;
8232        procedure.updated_epoch = epoch.0;
8233        let command = crate::catalog_cmds::CatalogCommand::CreateProcedure {
8234            procedure: procedure.clone(),
8235        };
8236        let mut next_catalog = self.catalog.read().clone();
8237        self.apply_catalog_command_to(&mut next_catalog, command)?;
8238        next_catalog.db_epoch = epoch.0;
8239        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
8240        Ok(procedure)
8241    }
8242
8243    pub fn create_or_replace_procedure(
8244        &self,
8245        procedure: StoredProcedure,
8246    ) -> Result<StoredProcedure> {
8247        self.create_or_replace_procedure_inner(procedure, None)
8248    }
8249
8250    pub fn create_or_replace_procedure_controlled<F>(
8251        &self,
8252        procedure: StoredProcedure,
8253        mut before_publish: F,
8254    ) -> Result<StoredProcedure>
8255    where
8256        F: FnMut() -> Result<()>,
8257    {
8258        self.create_or_replace_procedure_inner(procedure, Some(&mut before_publish))
8259    }
8260
8261    fn create_or_replace_procedure_inner(
8262        &self,
8263        procedure: StoredProcedure,
8264        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
8265    ) -> Result<StoredProcedure> {
8266        let command = crate::catalog_cmds::CatalogCommand::ReplaceProcedure {
8267            procedure: procedure.clone(),
8268        };
8269        self.require(&crate::catalog_cmds::required_permission(&command))?;
8270        let _g = self.ddl_lock.lock();
8271        let _security_write = self.security_write()?;
8272        self.require(&crate::catalog_cmds::required_permission(&command))?;
8273        procedure.validate()?;
8274        self.validate_procedure_references(&procedure)?;
8275        // S1F-001: validation runs pure against the current catalog first so
8276        // structural failures surface before an epoch is consumed, exactly
8277        // like the legacy pre-bump checks.
8278        crate::catalog_cmds::apply(&self.catalog.read(), &command)?;
8279        let commit_lock = Arc::clone(&self.commit_lock);
8280        let _c = commit_lock.lock();
8281        let epoch = self.epoch.bump_assigned();
8282        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
8283        let mut next_catalog = self.catalog.read().clone();
8284        // Resolve the replacement image against the candidate catalog: the
8285        // engine stamps version/epochs from the commit epoch (preserving the
8286        // original created_epoch on replacement), then the command record
8287        // carries that resolved image.
8288        let replaced = match next_catalog
8289            .procedures
8290            .iter()
8291            .position(|p| p.procedure.name == procedure.name)
8292        {
8293            Some(idx) => next_catalog.procedures[idx]
8294                .procedure
8295                .replaced(procedure.clone(), epoch.0)?,
8296            None => {
8297                let mut next = procedure;
8298                next.created_epoch = epoch.0;
8299                next.updated_epoch = epoch.0;
8300                next
8301            }
8302        };
8303        let command = crate::catalog_cmds::CatalogCommand::ReplaceProcedure {
8304            procedure: replaced.clone(),
8305        };
8306        self.apply_catalog_command_to(&mut next_catalog, command)?;
8307        next_catalog.db_epoch = epoch.0;
8308        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
8309        Ok(replaced)
8310    }
8311
8312    pub fn drop_procedure(&self, name: &str) -> Result<()> {
8313        self.drop_procedure_with_epoch(name).map(|_| ())
8314    }
8315
8316    pub fn drop_procedure_with_epoch(&self, name: &str) -> Result<Epoch> {
8317        self.drop_procedure_with_epoch_inner(name, None)
8318    }
8319
8320    pub fn drop_procedure_with_epoch_controlled<F>(
8321        &self,
8322        name: &str,
8323        mut before_publish: F,
8324    ) -> Result<Epoch>
8325    where
8326        F: FnMut() -> Result<()>,
8327    {
8328        self.drop_procedure_with_epoch_inner(name, Some(&mut before_publish))
8329    }
8330
8331    fn drop_procedure_with_epoch_inner(
8332        &self,
8333        name: &str,
8334        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
8335    ) -> Result<Epoch> {
8336        let command = crate::catalog_cmds::CatalogCommand::DropProcedure {
8337            name: name.to_string(),
8338        };
8339        self.require(&crate::catalog_cmds::required_permission(&command))?;
8340        let _g = self.ddl_lock.lock();
8341        let _security_write = self.security_write()?;
8342        self.require(&crate::catalog_cmds::required_permission(&command))?;
8343        let commit_lock = Arc::clone(&self.commit_lock);
8344        let _c = commit_lock.lock();
8345        let epoch = self.epoch.bump_assigned();
8346        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
8347        let mut next_catalog = self.catalog.read().clone();
8348        self.apply_catalog_command_to(&mut next_catalog, command)?;
8349        next_catalog.db_epoch = epoch.0;
8350        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
8351        Ok(epoch)
8352    }
8353
8354    // ── User / role / credentials management ─────────────────────────────
8355
8356    /// List all catalog users (password hashes included — callers should not
8357    /// serialize them externally).
8358    pub fn users(&self) -> Vec<crate::auth::UserEntry> {
8359        self.catalog.read().users.clone()
8360    }
8361
8362    /// Resolve only the stable, non-secret identity fields needed to scope
8363    /// request receipts. Password hashes never leave the catalog lock.
8364    pub fn user_identity(&self, username: &str) -> Option<(u64, u64)> {
8365        self.catalog
8366            .read()
8367            .users
8368            .iter()
8369            .find(|user| user.username == username)
8370            .map(|user| (user.id, user.created_epoch))
8371    }
8372
8373    /// SCRAM verifier material for the current catalog user generation.
8374    pub fn user_scram_verifier(
8375        &self,
8376        username: &str,
8377    ) -> Option<crate::security_hardening::ScramVerifier> {
8378        self.catalog
8379            .read()
8380            .users
8381            .iter()
8382            .find(|user| user.username == username)
8383            .and_then(|user| user.scram_sha_256.clone())
8384    }
8385
8386    /// Current catalog authorization generation. Retry bindings can include
8387    /// this value to fail closed after roles, grants, or row policies change.
8388    pub fn security_version(&self) -> u64 {
8389        self.catalog.read().security_version
8390    }
8391
8392    /// List all catalog roles.
8393    pub fn roles(&self) -> Vec<crate::auth::RoleEntry> {
8394        self.catalog.read().roles.clone()
8395    }
8396
8397    /// Create a new user with an Argon2id-hashed password.
8398    pub fn create_user(&self, username: &str, password: &str) -> Result<crate::auth::UserEntry> {
8399        self.require(&crate::auth::Permission::Admin)?;
8400        let hash = crate::auth::hash_password(password).map_err(MongrelError::Other)?;
8401        let scram = crate::auth::scram_verifier(password).map_err(MongrelError::Other)?;
8402        let mysql = crate::auth::MysqlCachingSha2Verifier::from_password(password);
8403        self.create_user_with_password_hash_inner(username, hash, Some((scram, mysql)), None)
8404    }
8405
8406    /// Create a user from a password hash prepared before a commit fence.
8407    pub fn create_user_with_password_hash(
8408        &self,
8409        username: &str,
8410        hash: String,
8411    ) -> Result<crate::auth::UserEntry> {
8412        self.create_user_with_password_hash_inner(username, hash, None, None)
8413    }
8414
8415    pub fn create_user_with_password_hash_controlled<F>(
8416        &self,
8417        username: &str,
8418        hash: String,
8419        mut before_publish: F,
8420    ) -> Result<crate::auth::UserEntry>
8421    where
8422        F: FnMut() -> Result<()>,
8423    {
8424        self.create_user_with_password_hash_inner(username, hash, None, Some(&mut before_publish))
8425    }
8426
8427    fn create_user_with_password_hash_inner(
8428        &self,
8429        username: &str,
8430        hash: String,
8431        verifiers: Option<(
8432            crate::security_hardening::ScramVerifier,
8433            crate::auth::MysqlCachingSha2Verifier,
8434        )>,
8435        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
8436    ) -> Result<crate::auth::UserEntry> {
8437        // S1F-001: the mutation is a versioned catalog command; its required
8438        // permission is checked against the caller principal first (identical
8439        // to the legacy hardcoded Admin gate).
8440        let command = match &verifiers {
8441            Some((scram_sha_256, mysql_caching_sha2)) => {
8442                crate::catalog_cmds::CatalogCommand::CreateUserWithAuthVerifiers {
8443                    username: username.to_string(),
8444                    password_hash: hash.clone(),
8445                    scram_sha_256: scram_sha_256.clone(),
8446                    mysql_caching_sha2: mysql_caching_sha2.clone(),
8447                    is_admin: false,
8448                    created_epoch: 0,
8449                }
8450            }
8451            None => crate::catalog_cmds::CatalogCommand::CreateUser {
8452                username: username.to_string(),
8453                password_hash: hash.clone(),
8454                is_admin: false,
8455                created_epoch: 0,
8456            },
8457        };
8458        self.require(&crate::catalog_cmds::required_permission(&command))?;
8459        let _ddl = self.ddl_lock.lock();
8460        let _security_write = self.security_write()?;
8461        self.require(&crate::catalog_cmds::required_permission(&command))?;
8462        let _commit = self.commit_lock.lock();
8463        let epoch = self.epoch.bump_assigned();
8464        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
8465        let command = match verifiers {
8466            Some((scram_sha_256, mysql_caching_sha2)) => {
8467                crate::catalog_cmds::CatalogCommand::CreateUserWithAuthVerifiers {
8468                    username: username.to_string(),
8469                    password_hash: hash,
8470                    scram_sha_256,
8471                    mysql_caching_sha2,
8472                    is_admin: false,
8473                    created_epoch: epoch.0,
8474                }
8475            }
8476            None => crate::catalog_cmds::CatalogCommand::CreateUser {
8477                username: username.to_string(),
8478                password_hash: hash,
8479                is_admin: false,
8480                created_epoch: epoch.0,
8481            },
8482        };
8483        let mut next_catalog = self.catalog.read().clone();
8484        let delta = self.apply_catalog_command_to(&mut next_catalog, command)?;
8485        let crate::catalog_cmds::CatalogDelta::UserUpserted(entry) = delta else {
8486            return Err(MongrelError::Other(
8487                "CreateUser resolved to an unexpected catalog delta".into(),
8488            ));
8489        };
8490        next_catalog.db_epoch = epoch.0;
8491        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
8492        Ok(entry)
8493    }
8494
8495    /// Drop a user by username.
8496    pub fn drop_user(&self, username: &str) -> Result<()> {
8497        self.drop_user_with_epoch(username).map(|_| ())
8498    }
8499
8500    pub fn drop_user_with_epoch(&self, username: &str) -> Result<Epoch> {
8501        self.drop_user_with_epoch_inner(username, None)
8502    }
8503
8504    pub fn drop_user_with_epoch_controlled<F>(
8505        &self,
8506        username: &str,
8507        mut before_publish: F,
8508    ) -> Result<Epoch>
8509    where
8510        F: FnMut() -> Result<()>,
8511    {
8512        self.drop_user_with_epoch_inner(username, Some(&mut before_publish))
8513    }
8514
8515    fn drop_user_with_epoch_inner(
8516        &self,
8517        username: &str,
8518        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
8519    ) -> Result<Epoch> {
8520        let command = crate::catalog_cmds::CatalogCommand::DropUser {
8521            username: username.to_string(),
8522        };
8523        self.require(&crate::catalog_cmds::required_permission(&command))?;
8524        let _ddl = self.ddl_lock.lock();
8525        let _security_write = self.security_write()?;
8526        self.require(&crate::catalog_cmds::required_permission(&command))?;
8527        let _commit = self.commit_lock.lock();
8528        let epoch = self.epoch.bump_assigned();
8529        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
8530        let mut next_catalog = self.catalog.read().clone();
8531        self.apply_catalog_command_to(&mut next_catalog, command)?;
8532        next_catalog.db_epoch = epoch.0;
8533        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
8534        Ok(epoch)
8535    }
8536
8537    /// Change a user's password.
8538    pub fn alter_user_password(&self, username: &str, new_password: &str) -> Result<()> {
8539        self.alter_user_password_with_epoch(username, new_password)
8540            .map(|_| ())
8541    }
8542
8543    pub fn alter_user_password_with_epoch(
8544        &self,
8545        username: &str,
8546        new_password: &str,
8547    ) -> Result<Epoch> {
8548        self.require(&crate::auth::Permission::Admin)?;
8549        let hash = crate::auth::hash_password(new_password).map_err(MongrelError::Other)?;
8550        let scram = crate::auth::scram_verifier(new_password).map_err(MongrelError::Other)?;
8551        let mysql = crate::auth::MysqlCachingSha2Verifier::from_password(new_password);
8552        self.alter_user_password_hash_with_epoch_inner(username, hash, Some((scram, mysql)), None)
8553    }
8554
8555    pub fn alter_user_password_hash_with_epoch(
8556        &self,
8557        username: &str,
8558        hash: String,
8559    ) -> Result<Epoch> {
8560        self.alter_user_password_hash_with_epoch_inner(username, hash, None, None)
8561    }
8562
8563    pub fn alter_user_password_hash_with_epoch_controlled<F>(
8564        &self,
8565        username: &str,
8566        hash: String,
8567        mut before_publish: F,
8568    ) -> Result<Epoch>
8569    where
8570        F: FnMut() -> Result<()>,
8571    {
8572        self.alter_user_password_hash_with_epoch_inner(
8573            username,
8574            hash,
8575            None,
8576            Some(&mut before_publish),
8577        )
8578    }
8579
8580    fn alter_user_password_hash_with_epoch_inner(
8581        &self,
8582        username: &str,
8583        hash: String,
8584        verifiers: Option<(
8585            crate::security_hardening::ScramVerifier,
8586            crate::auth::MysqlCachingSha2Verifier,
8587        )>,
8588        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
8589    ) -> Result<Epoch> {
8590        let command = match verifiers {
8591            Some((scram_sha_256, mysql_caching_sha2)) => {
8592                crate::catalog_cmds::CatalogCommand::AlterUserPasswordWithAuthVerifiers {
8593                    username: username.to_string(),
8594                    password_hash: hash,
8595                    scram_sha_256,
8596                    mysql_caching_sha2,
8597                }
8598            }
8599            None => crate::catalog_cmds::CatalogCommand::AlterUserPassword {
8600                username: username.to_string(),
8601                password_hash: hash,
8602            },
8603        };
8604        self.require(&crate::catalog_cmds::required_permission(&command))?;
8605        let _ddl = self.ddl_lock.lock();
8606        let _security_write = self.security_write()?;
8607        self.require(&crate::catalog_cmds::required_permission(&command))?;
8608        let _commit = self.commit_lock.lock();
8609        let epoch = self.epoch.bump_assigned();
8610        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
8611        let mut next_catalog = self.catalog.read().clone();
8612        self.apply_catalog_command_to(&mut next_catalog, command)?;
8613        next_catalog.db_epoch = epoch.0;
8614        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
8615        Ok(epoch)
8616    }
8617
8618    /// Verify credentials. Returns `Some(entry)` on success, `None` on
8619    /// mismatch, `Err` on engine error.
8620    pub fn verify_user(
8621        &self,
8622        username: &str,
8623        password: &str,
8624    ) -> Result<Option<crate::auth::UserEntry>> {
8625        let cat = self.catalog.read();
8626        let Some(user) = cat.users.iter().find(|u| u.username == username) else {
8627            return Ok(None);
8628        };
8629        if user.password_hash.is_empty() {
8630            return Ok(None);
8631        }
8632        let ok = crate::auth::verify_password(password, &user.password_hash)
8633            .map_err(MongrelError::Other)?;
8634        if ok {
8635            Ok(Some(user.clone()))
8636        } else {
8637            Ok(None)
8638        }
8639    }
8640
8641    /// Authenticate and resolve one immutable principal from the same catalog
8642    /// snapshot. Username reuse cannot bridge the password check and principal
8643    /// resolution.
8644    pub fn authenticate_principal(
8645        &self,
8646        username: &str,
8647        password: &str,
8648    ) -> Result<Option<crate::auth::Principal>> {
8649        self.authenticate_principal_inner(username, password, || {})
8650    }
8651
8652    fn authenticate_principal_inner<F>(
8653        &self,
8654        username: &str,
8655        password: &str,
8656        after_verify: F,
8657    ) -> Result<Option<crate::auth::Principal>>
8658    where
8659        F: FnOnce(),
8660    {
8661        let catalog = self.catalog.read();
8662        let Some(user) = catalog.users.iter().find(|user| user.username == username) else {
8663            return Ok(None);
8664        };
8665        if user.password_hash.is_empty()
8666            || !crate::auth::verify_password(password, &user.password_hash)
8667                .map_err(MongrelError::Other)?
8668        {
8669            return Ok(None);
8670        }
8671        after_verify();
8672        Ok(Self::resolve_user_principal_from_catalog(&catalog, user))
8673    }
8674
8675    /// Verify a MySQL 8 `caching_sha2_password` proof and resolve the same
8676    /// live catalog principal used by native SCRAM authentication.
8677    pub fn authenticate_mysql_caching_sha2_principal(
8678        &self,
8679        username: &str,
8680        nonce: &[u8],
8681        proof: &[u8],
8682    ) -> Option<crate::auth::Principal> {
8683        let catalog = self.catalog.read();
8684        let user = catalog
8685            .users
8686            .iter()
8687            .find(|user| user.username == username)?;
8688        if !user.mysql_caching_sha2.as_ref()?.verify(nonce, proof) {
8689            return None;
8690        }
8691        Self::resolve_user_principal_from_catalog(&catalog, user)
8692    }
8693
8694    /// Grant admin privileges to a user (bypasses all permission checks).
8695    pub fn set_user_admin(&self, username: &str, is_admin: bool) -> Result<()> {
8696        self.set_user_admin_with_epoch(username, is_admin)
8697            .map(|_| ())
8698    }
8699
8700    pub fn set_user_admin_with_epoch(
8701        &self,
8702        username: &str,
8703        is_admin: bool,
8704    ) -> Result<Option<Epoch>> {
8705        self.set_user_admin_with_epoch_inner(username, is_admin, None)
8706    }
8707
8708    pub fn set_user_admin_with_epoch_controlled<F>(
8709        &self,
8710        username: &str,
8711        is_admin: bool,
8712        mut before_publish: F,
8713    ) -> Result<Option<Epoch>>
8714    where
8715        F: FnMut() -> Result<()>,
8716    {
8717        self.set_user_admin_with_epoch_inner(username, is_admin, Some(&mut before_publish))
8718    }
8719
8720    fn set_user_admin_with_epoch_inner(
8721        &self,
8722        username: &str,
8723        is_admin: bool,
8724        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
8725    ) -> Result<Option<Epoch>> {
8726        let command = crate::catalog_cmds::CatalogCommand::SetUserAdmin {
8727            username: username.to_string(),
8728            is_admin,
8729        };
8730        self.require(&crate::catalog_cmds::required_permission(&command))?;
8731        let _ddl = self.ddl_lock.lock();
8732        let _security_write = self.security_write()?;
8733        self.require(&crate::catalog_cmds::required_permission(&command))?;
8734        let _commit = self.commit_lock.lock();
8735        // Idempotent no-ops publish nothing and consume no epoch (the legacy
8736        // short-circuit), so validation runs pure first and only real changes
8737        // are applied and recorded.
8738        let delta = crate::catalog_cmds::apply(&self.catalog.read(), &command)?;
8739        if matches!(delta, crate::catalog_cmds::CatalogDelta::NoOp) {
8740            return Ok(None);
8741        }
8742        let epoch = self.epoch.bump_assigned();
8743        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
8744        let mut next_catalog = self.catalog.read().clone();
8745        self.apply_catalog_command_to(&mut next_catalog, command)?;
8746        next_catalog.db_epoch = epoch.0;
8747        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
8748        Ok(Some(epoch))
8749    }
8750
8751    /// Create a new role.
8752    pub fn create_role(&self, name: &str) -> Result<crate::auth::RoleEntry> {
8753        self.create_role_inner(name, None)
8754    }
8755
8756    pub fn create_role_controlled<F>(
8757        &self,
8758        name: &str,
8759        mut before_publish: F,
8760    ) -> Result<crate::auth::RoleEntry>
8761    where
8762        F: FnMut() -> Result<()>,
8763    {
8764        self.create_role_inner(name, Some(&mut before_publish))
8765    }
8766
8767    fn create_role_inner(
8768        &self,
8769        name: &str,
8770        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
8771    ) -> Result<crate::auth::RoleEntry> {
8772        let command = crate::catalog_cmds::CatalogCommand::CreateRole {
8773            name: name.to_string(),
8774            created_epoch: 0, // stamped after the commit epoch is assigned
8775        };
8776        self.require(&crate::catalog_cmds::required_permission(&command))?;
8777        let _ddl = self.ddl_lock.lock();
8778        let _security_write = self.security_write()?;
8779        self.require(&crate::catalog_cmds::required_permission(&command))?;
8780        let _commit = self.commit_lock.lock();
8781        let epoch = self.epoch.bump_assigned();
8782        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
8783        let command = crate::catalog_cmds::CatalogCommand::CreateRole {
8784            name: name.to_string(),
8785            created_epoch: epoch.0,
8786        };
8787        let mut next_catalog = self.catalog.read().clone();
8788        let delta = self.apply_catalog_command_to(&mut next_catalog, command)?;
8789        let crate::catalog_cmds::CatalogDelta::RoleUpserted(entry) = delta else {
8790            return Err(MongrelError::Other(
8791                "CreateRole resolved to an unexpected catalog delta".into(),
8792            ));
8793        };
8794        next_catalog.db_epoch = epoch.0;
8795        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
8796        Ok(entry)
8797    }
8798
8799    /// Drop a role by name.
8800    pub fn drop_role(&self, name: &str) -> Result<()> {
8801        self.drop_role_with_epoch(name).map(|_| ())
8802    }
8803
8804    pub fn drop_role_with_epoch(&self, name: &str) -> Result<Epoch> {
8805        self.drop_role_with_epoch_inner(name, None)
8806    }
8807
8808    pub fn drop_role_with_epoch_controlled<F>(
8809        &self,
8810        name: &str,
8811        mut before_publish: F,
8812    ) -> Result<Epoch>
8813    where
8814        F: FnMut() -> Result<()>,
8815    {
8816        self.drop_role_with_epoch_inner(name, Some(&mut before_publish))
8817    }
8818
8819    fn drop_role_with_epoch_inner(
8820        &self,
8821        name: &str,
8822        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
8823    ) -> Result<Epoch> {
8824        let command = crate::catalog_cmds::CatalogCommand::DropRole {
8825            name: name.to_string(),
8826        };
8827        self.require(&crate::catalog_cmds::required_permission(&command))?;
8828        let _ddl = self.ddl_lock.lock();
8829        let _security_write = self.security_write()?;
8830        self.require(&crate::catalog_cmds::required_permission(&command))?;
8831        let _commit = self.commit_lock.lock();
8832        let epoch = self.epoch.bump_assigned();
8833        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
8834        let mut next_catalog = self.catalog.read().clone();
8835        self.apply_catalog_command_to(&mut next_catalog, command)?;
8836        next_catalog.db_epoch = epoch.0;
8837        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
8838        Ok(epoch)
8839    }
8840
8841    /// Grant a role to a user.
8842    pub fn grant_role(&self, username: &str, role_name: &str) -> Result<()> {
8843        self.grant_role_with_epoch(username, role_name).map(|_| ())
8844    }
8845
8846    pub fn grant_role_with_epoch(&self, username: &str, role_name: &str) -> Result<Option<Epoch>> {
8847        self.grant_role_with_epoch_inner(username, role_name, None)
8848    }
8849
8850    pub fn grant_role_with_epoch_controlled<F>(
8851        &self,
8852        username: &str,
8853        role_name: &str,
8854        mut before_publish: F,
8855    ) -> Result<Option<Epoch>>
8856    where
8857        F: FnMut() -> Result<()>,
8858    {
8859        self.grant_role_with_epoch_inner(username, role_name, Some(&mut before_publish))
8860    }
8861
8862    fn grant_role_with_epoch_inner(
8863        &self,
8864        username: &str,
8865        role_name: &str,
8866        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
8867    ) -> Result<Option<Epoch>> {
8868        let command = crate::catalog_cmds::CatalogCommand::GrantRole {
8869            username: username.to_string(),
8870            role: role_name.to_string(),
8871        };
8872        self.require(&crate::catalog_cmds::required_permission(&command))?;
8873        let _ddl = self.ddl_lock.lock();
8874        let _security_write = self.security_write()?;
8875        self.require(&crate::catalog_cmds::required_permission(&command))?;
8876        let _commit = self.commit_lock.lock();
8877        // Idempotent no-ops publish nothing and consume no epoch (the legacy
8878        // short-circuit), so validation runs pure first and only real changes
8879        // are applied and recorded.
8880        let delta = crate::catalog_cmds::apply(&self.catalog.read(), &command)?;
8881        if matches!(delta, crate::catalog_cmds::CatalogDelta::NoOp) {
8882            return Ok(None);
8883        }
8884        let epoch = self.epoch.bump_assigned();
8885        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
8886        let mut next_catalog = self.catalog.read().clone();
8887        self.apply_catalog_command_to(&mut next_catalog, command)?;
8888        next_catalog.db_epoch = epoch.0;
8889        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
8890        Ok(Some(epoch))
8891    }
8892
8893    /// Revoke a role from a user.
8894    pub fn revoke_role(&self, username: &str, role_name: &str) -> Result<()> {
8895        self.revoke_role_with_epoch(username, role_name).map(|_| ())
8896    }
8897
8898    pub fn revoke_role_with_epoch(&self, username: &str, role_name: &str) -> Result<Option<Epoch>> {
8899        self.revoke_role_with_epoch_inner(username, role_name, None)
8900    }
8901
8902    pub fn revoke_role_with_epoch_controlled<F>(
8903        &self,
8904        username: &str,
8905        role_name: &str,
8906        mut before_publish: F,
8907    ) -> Result<Option<Epoch>>
8908    where
8909        F: FnMut() -> Result<()>,
8910    {
8911        self.revoke_role_with_epoch_inner(username, role_name, Some(&mut before_publish))
8912    }
8913
8914    fn revoke_role_with_epoch_inner(
8915        &self,
8916        username: &str,
8917        role_name: &str,
8918        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
8919    ) -> Result<Option<Epoch>> {
8920        let command = crate::catalog_cmds::CatalogCommand::RevokeRole {
8921            username: username.to_string(),
8922            role: role_name.to_string(),
8923        };
8924        self.require(&crate::catalog_cmds::required_permission(&command))?;
8925        let _ddl = self.ddl_lock.lock();
8926        let _security_write = self.security_write()?;
8927        self.require(&crate::catalog_cmds::required_permission(&command))?;
8928        let _commit = self.commit_lock.lock();
8929        // Idempotent no-ops publish nothing and consume no epoch (the legacy
8930        // short-circuit), so validation runs pure first and only real changes
8931        // are applied and recorded.
8932        let delta = crate::catalog_cmds::apply(&self.catalog.read(), &command)?;
8933        if matches!(delta, crate::catalog_cmds::CatalogDelta::NoOp) {
8934            return Ok(None);
8935        }
8936        let epoch = self.epoch.bump_assigned();
8937        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
8938        let mut next_catalog = self.catalog.read().clone();
8939        self.apply_catalog_command_to(&mut next_catalog, command)?;
8940        next_catalog.db_epoch = epoch.0;
8941        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
8942        Ok(Some(epoch))
8943    }
8944
8945    /// Grant a permission to a role.
8946    pub fn grant_permission(
8947        &self,
8948        role_name: &str,
8949        permission: crate::auth::Permission,
8950    ) -> Result<()> {
8951        self.grant_permission_with_epoch(role_name, permission)
8952            .map(|_| ())
8953    }
8954
8955    pub fn grant_permission_with_epoch(
8956        &self,
8957        role_name: &str,
8958        permission: crate::auth::Permission,
8959    ) -> Result<Option<Epoch>> {
8960        self.grant_permission_with_epoch_inner(role_name, permission, None)
8961    }
8962
8963    pub fn grant_permission_with_epoch_controlled<F>(
8964        &self,
8965        role_name: &str,
8966        permission: crate::auth::Permission,
8967        mut before_publish: F,
8968    ) -> Result<Option<Epoch>>
8969    where
8970        F: FnMut() -> Result<()>,
8971    {
8972        self.grant_permission_with_epoch_inner(role_name, permission, Some(&mut before_publish))
8973    }
8974
8975    fn grant_permission_with_epoch_inner(
8976        &self,
8977        role_name: &str,
8978        permission: crate::auth::Permission,
8979        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
8980    ) -> Result<Option<Epoch>> {
8981        let command = crate::catalog_cmds::CatalogCommand::GrantPermission {
8982            role: role_name.to_string(),
8983            permission,
8984        };
8985        self.require(&crate::catalog_cmds::required_permission(&command))?;
8986        let _ddl = self.ddl_lock.lock();
8987        let _security_write = self.security_write()?;
8988        self.require(&crate::catalog_cmds::required_permission(&command))?;
8989        let _commit = self.commit_lock.lock();
8990        // Idempotent no-ops publish nothing and consume no epoch (the legacy
8991        // short-circuit), so validation runs pure first and only real changes
8992        // are applied and recorded.
8993        let delta = crate::catalog_cmds::apply(&self.catalog.read(), &command)?;
8994        if matches!(delta, crate::catalog_cmds::CatalogDelta::NoOp) {
8995            return Ok(None);
8996        }
8997        let epoch = self.epoch.bump_assigned();
8998        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
8999        let mut next_catalog = self.catalog.read().clone();
9000        self.apply_catalog_command_to(&mut next_catalog, command)?;
9001        next_catalog.db_epoch = epoch.0;
9002        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
9003        Ok(Some(epoch))
9004    }
9005
9006    /// Revoke a permission from a role.
9007    pub fn revoke_permission(
9008        &self,
9009        role_name: &str,
9010        permission: crate::auth::Permission,
9011    ) -> Result<()> {
9012        self.revoke_permission_with_epoch(role_name, permission)
9013            .map(|_| ())
9014    }
9015
9016    pub fn revoke_permission_with_epoch(
9017        &self,
9018        role_name: &str,
9019        permission: crate::auth::Permission,
9020    ) -> Result<Option<Epoch>> {
9021        self.revoke_permission_with_epoch_inner(role_name, permission, None)
9022    }
9023
9024    pub fn revoke_permission_with_epoch_controlled<F>(
9025        &self,
9026        role_name: &str,
9027        permission: crate::auth::Permission,
9028        mut before_publish: F,
9029    ) -> Result<Option<Epoch>>
9030    where
9031        F: FnMut() -> Result<()>,
9032    {
9033        self.revoke_permission_with_epoch_inner(role_name, permission, Some(&mut before_publish))
9034    }
9035
9036    fn revoke_permission_with_epoch_inner(
9037        &self,
9038        role_name: &str,
9039        permission: crate::auth::Permission,
9040        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
9041    ) -> Result<Option<Epoch>> {
9042        let command = crate::catalog_cmds::CatalogCommand::RevokePermission {
9043            role: role_name.to_string(),
9044            permission,
9045        };
9046        self.require(&crate::catalog_cmds::required_permission(&command))?;
9047        let _ddl = self.ddl_lock.lock();
9048        let _security_write = self.security_write()?;
9049        self.require(&crate::catalog_cmds::required_permission(&command))?;
9050        let _commit = self.commit_lock.lock();
9051        // Idempotent no-ops publish nothing and consume no epoch (the legacy
9052        // short-circuit), so validation runs pure first and only real changes
9053        // are applied and recorded.
9054        let delta = crate::catalog_cmds::apply(&self.catalog.read(), &command)?;
9055        if matches!(delta, crate::catalog_cmds::CatalogDelta::NoOp) {
9056            return Ok(None);
9057        }
9058        let epoch = self.epoch.bump_assigned();
9059        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
9060        let mut next_catalog = self.catalog.read().clone();
9061        self.apply_catalog_command_to(&mut next_catalog, command)?;
9062        next_catalog.db_epoch = epoch.0;
9063        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
9064        Ok(Some(epoch))
9065    }
9066
9067    /// Resolve a user into a [`crate::auth::Principal`] by collecting all
9068    /// permissions from their roles. Returns `None` if the user doesn't exist.
9069    pub fn resolve_principal(&self, username: &str) -> Option<crate::auth::Principal> {
9070        let cat = self.catalog.read();
9071        Self::resolve_principal_from_catalog(&cat, username)
9072    }
9073
9074    /// Re-resolve only when the immutable user identity still exists. This is
9075    /// the server/session validation path; username reuse never matches.
9076    pub fn resolve_current_principal(
9077        &self,
9078        principal: &crate::auth::Principal,
9079    ) -> Option<crate::auth::Principal> {
9080        Self::resolve_bound_principal_from_catalog(&self.catalog.read(), principal)
9081    }
9082
9083    /// Resolve a username to a [`Principal`] directly from a catalog snapshot,
9084    /// without needing a constructed `Database`. Used by the credentialed open
9085    /// path (which must verify credentials before the `Database` exists) and
9086    /// by [`resolve_principal`](Self::resolve_principal).
9087    fn resolve_principal_from_catalog(
9088        cat: &Catalog,
9089        username: &str,
9090    ) -> Option<crate::auth::Principal> {
9091        let user = cat.users.iter().find(|u| u.username == username)?;
9092        Self::resolve_user_principal_from_catalog(cat, user)
9093    }
9094
9095    fn resolve_bound_principal_from_catalog(
9096        cat: &Catalog,
9097        principal: &crate::auth::Principal,
9098    ) -> Option<crate::auth::Principal> {
9099        let user = cat.users.iter().find(|user| {
9100            user.id == principal.user_id
9101                && user.created_epoch == principal.created_epoch
9102                && user.username == principal.username
9103        })?;
9104        Self::resolve_user_principal_from_catalog(cat, user)
9105    }
9106
9107    fn resolve_user_principal_from_catalog(
9108        cat: &Catalog,
9109        user: &crate::auth::UserEntry,
9110    ) -> Option<crate::auth::Principal> {
9111        let mut permissions = Vec::new();
9112        for role_name in &user.roles {
9113            if let Some(role) = cat.roles.iter().find(|r| &r.name == role_name) {
9114                permissions.extend(role.permissions.iter().cloned());
9115            }
9116        }
9117        Some(crate::auth::Principal {
9118            user_id: user.id,
9119            created_epoch: user.created_epoch,
9120            username: user.username.clone(),
9121            is_admin: user.is_admin,
9122            roles: user.roles.clone(),
9123            permissions,
9124        })
9125    }
9126
9127    /// Check whether a user has a specific permission (via their roles).
9128    pub fn check_permission(&self, username: &str, permission: &crate::auth::Permission) -> bool {
9129        match self.resolve_principal(username) {
9130            Some(p) => p.has_permission(permission),
9131            None => false,
9132        }
9133    }
9134
9135    /// Returns `true` if this database's catalog has `require_auth = true`.
9136    /// When true, every operation consults the cached [`Principal`] via
9137    /// [`require`](Self::require).
9138    pub fn require_auth_enabled(&self) -> bool {
9139        self.catalog.read().require_auth
9140    }
9141
9142    /// A snapshot of the cached principal for this handle, if any. `None` for
9143    /// databases opened without credentials (the default). Returns a clone
9144    /// because the principal lives behind an `RwLock`.
9145    pub fn principal(&self) -> Option<crate::auth::Principal> {
9146        self.principal.read().clone()
9147    }
9148
9149    /// Build a `TableAuthChecker` from the current auth state. Used when
9150    /// mounting a new table (`create_table`) so the table inherits the
9151    /// database's enforcement configuration. The checker reads the live
9152    /// `require_auth` flag and cached principal, so changes via `enable_auth`
9153    /// / `refresh_principal` propagate to already-mounted tables.
9154    fn table_auth_checker(&self) -> Option<Arc<dyn crate::auth_state::TableAuthChecker>> {
9155        if self.shared {
9156            return None;
9157        }
9158        Some(Arc::new(crate::auth_state::DefaultTableAuthChecker::new(
9159            self.auth_state.clone(),
9160        )))
9161    }
9162
9163    /// Re-resolve the cached principal from the shared current catalog.
9164    /// Long-lived
9165    /// handles (e.g. a daemon) call this after a `REVOKE` or role change —
9166    /// possibly made by a different handle to the same database — to pick up
9167    /// the new effective permissions without re-verifying the password.
9168    ///
9169    /// The process-wide security version reloads from disk only when another
9170    /// handle published a newer catalog. The username is taken from
9171    /// the existing cached principal; if the user has since been dropped,
9172    /// returns [`MongrelError::InvalidCredentials`].
9173    ///
9174    /// No-op (returns `Ok(())`) on a credentialless database, or on a
9175    /// credentialed database whose cached principal is `None`.
9176    pub fn refresh_principal(&self) -> Result<()> {
9177        let previous = match self.principal.read().clone() {
9178            Some(principal) => principal,
9179            None => return Ok(()),
9180        };
9181        // Service principals are runtime identities, not catalog users. Their
9182        // immutable scopes are stored on the handle and never re-resolved by
9183        // username.
9184        if previous.user_id == 0 {
9185            return Ok(());
9186        }
9187        let observed_version = self.security_coordinator.version.load(Ordering::Acquire);
9188        self.refresh_security_catalog_if_stale(observed_version)?;
9189        let cat = self.catalog.read();
9190        match Self::resolve_bound_principal_from_catalog(&cat, &previous) {
9191            Some(p) => {
9192                *self.principal.write() = Some(p.clone());
9193                // Update the shared auth state so mounted Tables see the new
9194                // permissions immediately (Tables read from AuthState, not from
9195                // self.principal).
9196                self.auth_state.set_principal(Some(p));
9197                Ok(())
9198            }
9199            None => Err(MongrelError::InvalidCredentials {
9200                username: previous.username,
9201            }),
9202        }
9203    }
9204
9205    /// Number of security-catalog disk reloads performed by this open handle.
9206    /// Initial open reads are excluded.
9207    pub fn security_catalog_disk_read_count(&self) -> u64 {
9208        self.security_catalog_disk_reads.load(Ordering::Relaxed)
9209    }
9210
9211    /// Convert a credentialless database to a credentialed one: create the
9212    /// first admin user, set `require_auth = true`, and cache the admin
9213    /// principal on this handle so subsequent operations on the same handle
9214    /// continue to work. After this call, the database can only be reopened
9215    /// via `open_with_credentials` / `open_encrypted_with_credentials`.
9216    ///
9217    /// Refuses if the database already has `require_auth = true`. This is
9218    /// the conversion path for existing databases; for fresh databases,
9219    /// `create_with_credentials` sets everything up atomically.
9220    ///
9221    /// See `docs/15-credential-enforcement.md`.
9222    pub fn enable_auth(&self, admin_username: &str, admin_password: &str) -> Result<()> {
9223        if self.shared {
9224            // Fail closed (spec §4.6): one shared handle must not flip the
9225            // core into an enforcement mode the other handles cannot observe.
9226            // Stage 1A shared cores stay credentialless; auth-mode transitions
9227            // require an exclusive `Database` owner. Per-handle principals
9228            // land with Stage 1D sessions.
9229            return Err(MongrelError::Conflict(
9230                "enable_auth requires an exclusive Database owner; shared cores reject \
9231                 auth-mode transitions"
9232                    .into(),
9233            ));
9234        }
9235        let password_hash =
9236            crate::auth::hash_password(admin_password).map_err(MongrelError::Other)?;
9237        let scram_sha_256 =
9238            crate::auth::scram_verifier(admin_password).map_err(MongrelError::Other)?;
9239        let mysql_caching_sha2 =
9240            crate::auth::MysqlCachingSha2Verifier::from_password(admin_password);
9241        let _ddl = self.ddl_lock.lock();
9242        let _security_write = self.security_write()?;
9243        let _commit = self.commit_lock.lock();
9244        let epoch = self.epoch.bump_assigned();
9245        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
9246        let mut next_catalog = self.catalog.read().clone();
9247        if next_catalog.require_auth {
9248            return Err(MongrelError::InvalidArgument(
9249                "database already has require_auth enabled".into(),
9250            ));
9251        }
9252        if next_catalog
9253            .users
9254            .iter()
9255            .any(|u| u.username == admin_username)
9256        {
9257            return Err(MongrelError::InvalidArgument(format!(
9258                "user {admin_username:?} already exists"
9259            )));
9260        }
9261        next_catalog.next_user_id = next_catalog.next_user_id.max(1);
9262        let id = next_catalog.next_user_id;
9263        next_catalog.next_user_id = id
9264            .checked_add(1)
9265            .ok_or_else(|| MongrelError::Full("user-id namespace exhausted".into()))?;
9266        next_catalog.users.push(crate::auth::UserEntry {
9267            id,
9268            username: admin_username.to_string(),
9269            password_hash,
9270            scram_sha_256: Some(scram_sha_256),
9271            mysql_caching_sha2: Some(mysql_caching_sha2),
9272            roles: Vec::new(),
9273            is_admin: true,
9274            created_epoch: epoch.0,
9275        });
9276        next_catalog.require_auth = true;
9277        advance_security_version(&mut next_catalog)?;
9278        next_catalog.db_epoch = epoch.0;
9279        let publish = self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, None);
9280        // Cache the admin principal on this handle + update the shared auth
9281        // state whenever rename published, even if directory fsync was
9282        // inconclusive.
9283        if publish.is_ok() || matches!(&publish, Err(MongrelError::CommitOutcomeUnknown { .. })) {
9284            let principal = crate::auth::Principal {
9285                user_id: id,
9286                created_epoch: epoch.0,
9287                username: admin_username.to_string(),
9288                is_admin: true,
9289                roles: Vec::new(),
9290                permissions: Vec::new(),
9291            };
9292            *self.principal.write() = Some(principal.clone());
9293            self.auth_state.set_principal(Some(principal));
9294        }
9295        publish
9296    }
9297
9298    /// Disable `require_auth` on this database, reverting it to credentialless
9299    /// mode. This is the **recovery** path — it requires the handle to already
9300    /// be open (and therefore already authenticated if `require_auth` was on).
9301    ///
9302    /// After this call, the database can be reopened with plain
9303    /// [`open`](Self::open) / [`open_encrypted`](Self::open_encrypted) without
9304    /// credentials. All existing users and roles are preserved in the catalog
9305    /// (so `require_auth` can be re-enabled without recreating them), but they
9306    /// are no longer consulted for enforcement.
9307    ///
9308    /// For true **offline** recovery (when credentials are lost and no
9309    /// authenticated handle is available), the caller opens the database
9310    /// directly via the catalog file (filesystem access required) and calls
9311    /// this method — see the CLI's `auth disable-offline` command.
9312    ///
9313    /// See `docs/15-credential-enforcement.md` §4.7.
9314    pub fn disable_auth(&self) -> Result<()> {
9315        let _ddl = self.ddl_lock.lock();
9316        let _security_write = self.security_write()?;
9317        let _commit = self.commit_lock.lock();
9318        let epoch = self.epoch.bump_assigned();
9319        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
9320        let mut next_catalog = self.catalog.read().clone();
9321        if !next_catalog.require_auth {
9322            return Err(MongrelError::InvalidArgument(
9323                "database does not have require_auth enabled".into(),
9324            ));
9325        }
9326        next_catalog.require_auth = false;
9327        advance_security_version(&mut next_catalog)?;
9328        next_catalog.db_epoch = epoch.0;
9329        let publish = self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, None);
9330        // Clear the cached principal — enforcement is now off.
9331        if publish.is_ok() || matches!(&publish, Err(MongrelError::CommitOutcomeUnknown { .. })) {
9332            *self.principal.write() = None;
9333        }
9334        publish
9335    }
9336
9337    /// Enforcement check: if the catalog has `require_auth = true`, verify
9338    /// that the cached principal satisfies `perm`. Called by every
9339    /// enforcement point (DDL, admin, maintenance, and — in Phase 2 —
9340    /// Table/Transaction/MongrelSession operations).
9341    ///
9342    /// On a credentialless database this is a no-op (`Ok(())`).
9343    pub fn require(&self, perm: &crate::auth::Permission) -> Result<()> {
9344        self.ensure_owner_process()?;
9345        if self.read_only && !matches!(perm, crate::auth::Permission::Select { .. }) {
9346            return Err(MongrelError::ReadOnlyReplica);
9347        }
9348        if self.principal.read().is_some() {
9349            self.refresh_principal().map_err(|error| match error {
9350                MongrelError::InvalidCredentials { .. } => MongrelError::AuthRequired,
9351                error => error,
9352            })?;
9353        }
9354        if !self.catalog.read().require_auth {
9355            return Ok(());
9356        }
9357        let guard = self.principal.read();
9358        let p = guard.as_ref().ok_or(MongrelError::AuthRequired)?;
9359        if p.has_permission(perm) {
9360            Ok(())
9361        } else {
9362            Err(MongrelError::PermissionDenied {
9363                required: perm.clone(),
9364                principal: p.username.clone(),
9365            })
9366        }
9367    }
9368
9369    /// Convenience: enforce a table-level permission (`Select`/`Insert`/
9370    /// `Update`/`Delete`) by table name. Used by the Transaction layer and
9371    /// other callers that know the operation kind + table name but don't want
9372    /// to construct the full `Permission` enum value themselves.
9373    pub fn require_table(
9374        &self,
9375        table: &str,
9376        perm: crate::auth_state::RequiredPermission,
9377    ) -> Result<()> {
9378        self.require(&perm.into_permission(table))
9379    }
9380
9381    pub fn triggers(&self) -> Vec<StoredTrigger> {
9382        self.catalog
9383            .read()
9384            .triggers
9385            .iter()
9386            .map(|t| t.trigger.clone())
9387            .collect()
9388    }
9389
9390    pub fn trigger(&self, name: &str) -> Option<StoredTrigger> {
9391        self.catalog
9392            .read()
9393            .triggers
9394            .iter()
9395            .find(|t| t.trigger.name == name)
9396            .map(|t| t.trigger.clone())
9397    }
9398
9399    pub fn create_trigger(&self, trigger: StoredTrigger) -> Result<StoredTrigger> {
9400        self.create_trigger_inner(trigger, None, None)
9401    }
9402
9403    pub fn create_trigger_controlled<F>(
9404        &self,
9405        trigger: StoredTrigger,
9406        mut before_publish: F,
9407    ) -> Result<StoredTrigger>
9408    where
9409        F: FnMut() -> Result<()>,
9410    {
9411        self.create_trigger_inner(trigger, None, Some(&mut before_publish))
9412    }
9413
9414    pub fn create_trigger_as_controlled<F>(
9415        &self,
9416        trigger: StoredTrigger,
9417        principal: Option<&crate::auth::Principal>,
9418        mut before_publish: F,
9419    ) -> Result<StoredTrigger>
9420    where
9421        F: FnMut() -> Result<()>,
9422    {
9423        self.create_trigger_inner(trigger, principal, Some(&mut before_publish))
9424    }
9425
9426    fn create_trigger_inner(
9427        &self,
9428        mut trigger: StoredTrigger,
9429        principal: Option<&crate::auth::Principal>,
9430        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
9431    ) -> Result<StoredTrigger> {
9432        let command = crate::catalog_cmds::CatalogCommand::CreateTrigger {
9433            trigger: trigger.clone(),
9434        };
9435        self.require_for(
9436            principal,
9437            &crate::catalog_cmds::required_permission(&command),
9438        )?;
9439        let _g = self.ddl_lock.lock();
9440        let _security_write = self.security_write()?;
9441        self.require_for(
9442            principal,
9443            &crate::catalog_cmds::required_permission(&command),
9444        )?;
9445        trigger.validate()?;
9446        self.validate_trigger_references(&trigger)
9447            .map_err(trigger_validation_error)?;
9448        // S1F-001: validation runs pure against the current catalog first so
9449        // failures (duplicate name, unknown target) surface before an epoch is
9450        // consumed, exactly like the legacy pre-bump checks.
9451        crate::catalog_cmds::apply(&self.catalog.read(), &command)?;
9452        let commit_lock = Arc::clone(&self.commit_lock);
9453        let _c = commit_lock.lock();
9454        let epoch = self.epoch.bump_assigned();
9455        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
9456        trigger.created_epoch = epoch.0;
9457        trigger.updated_epoch = epoch.0;
9458        let command = crate::catalog_cmds::CatalogCommand::CreateTrigger {
9459            trigger: trigger.clone(),
9460        };
9461        let mut next_catalog = self.catalog.read().clone();
9462        self.apply_catalog_command_to(&mut next_catalog, command)?;
9463        next_catalog.db_epoch = epoch.0;
9464        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
9465        Ok(trigger)
9466    }
9467
9468    pub fn create_or_replace_trigger(&self, trigger: StoredTrigger) -> Result<StoredTrigger> {
9469        self.create_or_replace_trigger_inner(trigger, None, None)
9470    }
9471
9472    pub fn create_or_replace_trigger_controlled<F>(
9473        &self,
9474        trigger: StoredTrigger,
9475        mut before_publish: F,
9476    ) -> Result<StoredTrigger>
9477    where
9478        F: FnMut() -> Result<()>,
9479    {
9480        self.create_or_replace_trigger_inner(trigger, None, Some(&mut before_publish))
9481    }
9482
9483    pub fn create_or_replace_trigger_as_controlled<F>(
9484        &self,
9485        trigger: StoredTrigger,
9486        principal: Option<&crate::auth::Principal>,
9487        mut before_publish: F,
9488    ) -> Result<StoredTrigger>
9489    where
9490        F: FnMut() -> Result<()>,
9491    {
9492        self.create_or_replace_trigger_inner(trigger, principal, Some(&mut before_publish))
9493    }
9494
9495    fn create_or_replace_trigger_inner(
9496        &self,
9497        trigger: StoredTrigger,
9498        principal: Option<&crate::auth::Principal>,
9499        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
9500    ) -> Result<StoredTrigger> {
9501        let command = crate::catalog_cmds::CatalogCommand::ReplaceTrigger {
9502            trigger: trigger.clone(),
9503        };
9504        self.require_for(
9505            principal,
9506            &crate::catalog_cmds::required_permission(&command),
9507        )?;
9508        let _g = self.ddl_lock.lock();
9509        let _security_write = self.security_write()?;
9510        self.require_for(
9511            principal,
9512            &crate::catalog_cmds::required_permission(&command),
9513        )?;
9514        trigger.validate()?;
9515        self.validate_trigger_references(&trigger)
9516            .map_err(trigger_validation_error)?;
9517        // S1F-001: validation runs pure against the current catalog first so
9518        // structural failures surface before an epoch is consumed, exactly
9519        // like the legacy pre-bump checks.
9520        crate::catalog_cmds::apply(&self.catalog.read(), &command)?;
9521        let commit_lock = Arc::clone(&self.commit_lock);
9522        let _c = commit_lock.lock();
9523        let epoch = self.epoch.bump_assigned();
9524        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
9525        let mut next_catalog = self.catalog.read().clone();
9526        // Resolve the replacement image against the candidate catalog: the
9527        // engine stamps version/epochs from the commit epoch (preserving the
9528        // original created_epoch on replacement), then the command record
9529        // carries that resolved image.
9530        let replaced = match next_catalog
9531            .triggers
9532            .iter()
9533            .position(|t| t.trigger.name == trigger.name)
9534        {
9535            Some(idx) => next_catalog.triggers[idx]
9536                .trigger
9537                .replaced(trigger.clone(), epoch.0)?,
9538            None => {
9539                let mut next = trigger;
9540                next.created_epoch = epoch.0;
9541                next.updated_epoch = epoch.0;
9542                next
9543            }
9544        };
9545        let command = crate::catalog_cmds::CatalogCommand::ReplaceTrigger {
9546            trigger: replaced.clone(),
9547        };
9548        self.apply_catalog_command_to(&mut next_catalog, command)?;
9549        next_catalog.db_epoch = epoch.0;
9550        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
9551        Ok(replaced)
9552    }
9553
9554    pub fn drop_trigger(&self, name: &str) -> Result<()> {
9555        self.drop_trigger_with_epoch(name).map(|_| ())
9556    }
9557
9558    /// Drop one trigger and return the exact catalog publication epoch.
9559    pub fn drop_trigger_with_epoch(&self, name: &str) -> Result<Epoch> {
9560        self.drop_triggers_with_epoch(&[name.to_string()])
9561    }
9562
9563    pub fn drop_trigger_with_epoch_controlled<F>(
9564        &self,
9565        name: &str,
9566        before_publish: F,
9567    ) -> Result<Epoch>
9568    where
9569        F: FnMut() -> Result<()>,
9570    {
9571        self.drop_triggers_with_epoch_controlled(&[name.to_string()], before_publish)
9572    }
9573
9574    /// Atomically drop several triggers in one catalog publication.
9575    pub fn drop_triggers_with_epoch(&self, names: &[String]) -> Result<Epoch> {
9576        self.drop_triggers_with_epoch_inner(names, None, None)
9577    }
9578
9579    pub fn drop_triggers_with_epoch_controlled<F>(
9580        &self,
9581        names: &[String],
9582        mut before_publish: F,
9583    ) -> Result<Epoch>
9584    where
9585        F: FnMut() -> Result<()>,
9586    {
9587        self.drop_triggers_with_epoch_inner(names, None, Some(&mut before_publish))
9588    }
9589
9590    pub fn drop_triggers_with_epoch_as_controlled<F>(
9591        &self,
9592        names: &[String],
9593        principal: Option<&crate::auth::Principal>,
9594        mut before_publish: F,
9595    ) -> Result<Epoch>
9596    where
9597        F: FnMut() -> Result<()>,
9598    {
9599        self.drop_triggers_with_epoch_inner(names, principal, Some(&mut before_publish))
9600    }
9601
9602    fn drop_triggers_with_epoch_inner(
9603        &self,
9604        names: &[String],
9605        principal: Option<&crate::auth::Principal>,
9606        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
9607    ) -> Result<Epoch> {
9608        if names.is_empty() {
9609            return Err(MongrelError::InvalidArgument(
9610                "at least one trigger name is required".into(),
9611            ));
9612        }
9613        let commands: Vec<crate::catalog_cmds::CatalogCommand> = names
9614            .iter()
9615            .map(|name| crate::catalog_cmds::CatalogCommand::DropTrigger { name: name.clone() })
9616            .collect();
9617        for command in &commands {
9618            self.require_for(
9619                principal,
9620                &crate::catalog_cmds::required_permission(command),
9621            )?;
9622        }
9623        let _g = self.ddl_lock.lock();
9624        let _security_write = self.security_write()?;
9625        for command in &commands {
9626            self.require_for(
9627                principal,
9628                &crate::catalog_cmds::required_permission(command),
9629            )?;
9630        }
9631        // S1F-001: every name's command validates pure against the current
9632        // catalog first so a missing trigger surfaces before an epoch is
9633        // consumed, exactly like the legacy pre-bump check. A batch drop then
9634        // records one command per name (the emitting-layer expansion rule).
9635        {
9636            let cat = self.catalog.read();
9637            for command in &commands {
9638                crate::catalog_cmds::apply(&cat, command)?;
9639            }
9640        }
9641        let commit_lock = Arc::clone(&self.commit_lock);
9642        let _c = commit_lock.lock();
9643        let epoch = self.epoch.bump_assigned();
9644        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
9645        let mut next_catalog = self.catalog.read().clone();
9646        for command in commands {
9647            self.apply_catalog_command_to(&mut next_catalog, command)?;
9648        }
9649        next_catalog.db_epoch = epoch.0;
9650        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
9651        Ok(epoch)
9652    }
9653
9654    pub fn external_tables(&self) -> Vec<ExternalTableEntry> {
9655        self.catalog.read().external_tables.clone()
9656    }
9657
9658    pub fn external_table(&self, name: &str) -> Option<ExternalTableEntry> {
9659        self.catalog
9660            .read()
9661            .external_tables
9662            .iter()
9663            .find(|t| t.name == name)
9664            .cloned()
9665    }
9666
9667    pub fn create_external_table(&self, entry: ExternalTableEntry) -> Result<ExternalTableEntry> {
9668        self.create_external_table_inner(entry, None)
9669    }
9670
9671    pub fn create_external_table_controlled<F>(
9672        &self,
9673        entry: ExternalTableEntry,
9674        mut before_publish: F,
9675    ) -> Result<ExternalTableEntry>
9676    where
9677        F: FnMut() -> Result<()>,
9678    {
9679        self.create_external_table_inner(entry, Some(&mut before_publish))
9680    }
9681
9682    fn create_external_table_inner(
9683        &self,
9684        mut entry: ExternalTableEntry,
9685        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
9686    ) -> Result<ExternalTableEntry> {
9687        self.require(&crate::auth::Permission::Ddl)?;
9688        let _g = self.ddl_lock.lock();
9689        let _security_write = self.security_write()?;
9690        self.require(&crate::auth::Permission::Ddl)?;
9691        entry.validate()?;
9692        {
9693            let cat = self.catalog.read();
9694            if cat.live(&entry.name).is_some()
9695                || cat.external_tables.iter().any(|t| t.name == entry.name)
9696            {
9697                return Err(MongrelError::InvalidArgument(format!(
9698                    "table {:?} already exists",
9699                    entry.name
9700                )));
9701            }
9702        }
9703        let commit_lock = Arc::clone(&self.commit_lock);
9704        let _c = commit_lock.lock();
9705        // A prior durable drop may have left connector state behind if its
9706        // cleanup failed or the process crashed. Never let a new table with
9707        // the same name inherit that stale state.
9708        crate::durable_file::create_directory(&self.root.join(VTAB_DIR))?;
9709        crate::durable_file::remove_directory_all(&self.root.join(VTAB_DIR).join(&entry.name))?;
9710        let epoch = self.epoch.bump_assigned();
9711        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
9712        entry.created_epoch = epoch.0;
9713        let mut next_catalog = self.catalog.read().clone();
9714        next_catalog.external_tables.push(entry.clone());
9715        next_catalog.db_epoch = epoch.0;
9716        self.publish_catalog_candidate_with_prelude(
9717            next_catalog,
9718            epoch,
9719            &mut _epoch_guard,
9720            before_publish,
9721            vec![(
9722                EXTERNAL_TABLE_ID,
9723                crate::wal::Op::Ddl(crate::wal::DdlOp::ResetExternalTableState {
9724                    name: entry.name.clone(),
9725                    generation_epoch: epoch.0,
9726                }),
9727            )],
9728        )?;
9729        Ok(entry)
9730    }
9731
9732    pub fn drop_external_table(&self, name: &str) -> Result<()> {
9733        self.drop_external_table_with_epoch(name).map(|_| ())
9734    }
9735
9736    /// Drop an external table and return the exact catalog publication epoch.
9737    pub fn drop_external_table_with_epoch(&self, name: &str) -> Result<Epoch> {
9738        self.drop_external_table_with_epoch_inner(name, None)
9739    }
9740
9741    pub fn drop_external_table_with_epoch_controlled<F>(
9742        &self,
9743        name: &str,
9744        mut before_publish: F,
9745    ) -> Result<Epoch>
9746    where
9747        F: FnMut() -> Result<()>,
9748    {
9749        self.drop_external_table_with_epoch_inner(name, Some(&mut before_publish))
9750    }
9751
9752    fn drop_external_table_with_epoch_inner(
9753        &self,
9754        name: &str,
9755        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
9756    ) -> Result<Epoch> {
9757        self.require(&crate::auth::Permission::Ddl)?;
9758        let _g = self.ddl_lock.lock();
9759        let _security_write = self.security_write()?;
9760        self.require(&crate::auth::Permission::Ddl)?;
9761        let commit_lock = Arc::clone(&self.commit_lock);
9762        let _c = commit_lock.lock();
9763        let epoch = self.epoch.bump_assigned();
9764        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
9765        let mut next_catalog = self.catalog.read().clone();
9766        let before = next_catalog.external_tables.len();
9767        next_catalog.external_tables.retain(|t| t.name != name);
9768        if next_catalog.external_tables.len() == before {
9769            return Err(MongrelError::NotFound(format!(
9770                "external table {name:?} not found"
9771            )));
9772        }
9773        next_catalog.db_epoch = epoch.0;
9774        self.publish_catalog_candidate_with_prelude(
9775            next_catalog,
9776            epoch,
9777            &mut _epoch_guard,
9778            before_publish,
9779            vec![(
9780                EXTERNAL_TABLE_ID,
9781                crate::wal::Op::Ddl(crate::wal::DdlOp::ResetExternalTableState {
9782                    name: name.to_string(),
9783                    generation_epoch: epoch.0,
9784                }),
9785            )],
9786        )?;
9787        let state_dir = self.root.join(VTAB_DIR).join(name);
9788        if let Err(error) = crate::durable_file::remove_directory_all(&state_dir) {
9789            return Err(MongrelError::DurableCommit {
9790                epoch: epoch.0,
9791                message: format!(
9792                    "external table was dropped but connector-state cleanup failed: {error}"
9793                ),
9794            });
9795        }
9796        Ok(epoch)
9797    }
9798
9799    pub fn commit_external_table_state(&self, name: &str, state: &[u8]) -> Result<Epoch> {
9800        let txn_id = self.alloc_txn_id()?;
9801        let (principal, catalog_bound) = self.transaction_principal_snapshot();
9802        self.commit_transaction_with_external_states(
9803            txn_id,
9804            self.epoch.visible(),
9805            Vec::new(),
9806            vec![(name.to_string(), state.to_vec())],
9807            Vec::new(),
9808            principal,
9809            catalog_bound,
9810            None,
9811            crate::txn::TxnCommitContext::internal(),
9812        )
9813        .map(|(epoch, _)| epoch)
9814    }
9815
9816    pub fn trigger_config(&self) -> TriggerConfig {
9817        use std::sync::atomic::Ordering;
9818        TriggerConfig {
9819            recursive_triggers: self.trigger_recursive.load(Ordering::Relaxed),
9820            max_depth: self.trigger_max_depth.load(Ordering::Relaxed),
9821            max_loop_iterations: self.trigger_max_loop_iterations.load(Ordering::Relaxed),
9822        }
9823    }
9824
9825    pub fn set_trigger_config(&self, config: TriggerConfig) -> Result<()> {
9826        use std::sync::atomic::Ordering;
9827        if config.max_depth == 0 {
9828            return Err(MongrelError::InvalidArgument(
9829                "trigger max_depth must be greater than 0".into(),
9830            ));
9831        }
9832        self.trigger_recursive
9833            .store(config.recursive_triggers, Ordering::Relaxed);
9834        self.trigger_max_depth
9835            .store(config.max_depth, Ordering::Relaxed);
9836        self.trigger_max_loop_iterations
9837            .store(config.max_loop_iterations, Ordering::Relaxed);
9838        Ok(())
9839    }
9840
9841    pub fn set_recursive_triggers(&self, recursive: bool) {
9842        use std::sync::atomic::Ordering;
9843        self.trigger_recursive.store(recursive, Ordering::Relaxed);
9844    }
9845
9846    /// Subscribe to ephemeral SQL NOTIFY messages. Durable row changes use
9847    /// [`Self::change_events_since`], with [`Self::subscribe_change_commits`]
9848    /// as a low-latency wake-up.
9849    pub fn subscribe_changes(&self) -> tokio::sync::broadcast::Receiver<ChangeEvent> {
9850        self.notify.subscribe()
9851    }
9852
9853    pub fn subscribe_change_commits(&self) -> tokio::sync::broadcast::Receiver<()> {
9854        self.change_wake.subscribe()
9855    }
9856
9857    /// Reconstruct committed row changes from the retained shared WAL. Event
9858    /// ids are stable `<commit_epoch>:<operation_index>` pairs. A caller that
9859    /// resumes before the oldest retained commit receives `gap = true` and
9860    /// must rebootstrap instead of silently skipping changes.
9861    pub fn change_events_since(&self, last_event_id: Option<&str>) -> Result<CdcBatch> {
9862        let control = crate::ExecutionControl::new(None);
9863        self.change_events_since_controlled(last_event_id, &control)
9864    }
9865
9866    /// Reconstruct committed changes with cooperative cancellation and bounds.
9867    pub fn change_events_since_controlled(
9868        &self,
9869        last_event_id: Option<&str>,
9870        control: &crate::ExecutionControl,
9871    ) -> Result<CdcBatch> {
9872        use crate::wal::Op;
9873
9874        control.checkpoint()?;
9875        let resume = match last_event_id {
9876            Some(id) => {
9877                let (epoch, index) = id.split_once(':').ok_or_else(|| {
9878                    MongrelError::InvalidArgument(format!(
9879                        "invalid CDC event id {id:?}; expected <epoch>:<index>"
9880                    ))
9881                })?;
9882                Some((
9883                    epoch.parse::<u64>().map_err(|error| {
9884                        MongrelError::InvalidArgument(format!("invalid CDC epoch: {error}"))
9885                    })?,
9886                    index.parse::<u32>().map_err(|error| {
9887                        MongrelError::InvalidArgument(format!("invalid CDC index: {error}"))
9888                    })?,
9889                ))
9890            }
9891            None => None,
9892        };
9893
9894        let mut wal = self.shared_wal.lock();
9895        wal.group_sync()?;
9896        let wal_dek = crate::encryption::wal_dek_for(self.kek.as_deref());
9897        let records = crate::wal::SharedWal::replay_with_dek_controlled(
9898            &self.root,
9899            wal_dek.as_ref(),
9900            control,
9901            CDC_MAX_WAL_RECORDS,
9902            CDC_MAX_WAL_REPLAY_BYTES,
9903        )?;
9904        drop(wal);
9905        control.checkpoint()?;
9906
9907        let mut commits: HashMap<u64, (u64, Vec<crate::wal::AddedRun>)> = HashMap::new();
9908        let mut spilled_payloads: HashMap<(u64, u64), Vec<&[u8]>> = HashMap::new();
9909        for (index, record) in records.iter().enumerate() {
9910            if index % 256 == 0 {
9911                control.checkpoint()?;
9912            }
9913            if let Op::TxnCommit { epoch, added_runs } = &record.op {
9914                commits.insert(record.txn_id, (*epoch, added_runs.clone()));
9915            }
9916            if let Op::SpilledRows { table_id, rows } = &record.op {
9917                spilled_payloads
9918                    .entry((record.txn_id, *table_id))
9919                    .or_default()
9920                    .push(rows);
9921            }
9922        }
9923        let earliest_epoch = commits.values().map(|(epoch, _)| *epoch).min();
9924        let current_epoch = self.epoch.committed().0;
9925        let retention_floor = crate::replication::replication_wal_floor(&self.root)?;
9926        let gap = resume.is_some_and(|(epoch, _)| {
9927            retention_floor != 0 && epoch <= retention_floor && epoch <= current_epoch
9928        });
9929        if gap {
9930            return Ok(CdcBatch {
9931                events: Vec::new(),
9932                current_epoch,
9933                earliest_epoch,
9934                gap: true,
9935            });
9936        }
9937
9938        let table_names: HashMap<u64, String> = self
9939            .catalog
9940            .read()
9941            .tables
9942            .iter()
9943            .map(|entry| (entry.table_id, entry.name.clone()))
9944            .collect();
9945        let mut before_images: HashMap<(u64, u64, u64), crate::memtable::Row> = HashMap::new();
9946        let mut retained_bytes = 0_usize;
9947        for (index, record) in records.iter().enumerate() {
9948            if index % 256 == 0 {
9949                control.checkpoint()?;
9950            }
9951            if !commits.contains_key(&record.txn_id) {
9952                continue;
9953            }
9954            let Op::BeforeImage {
9955                table_id,
9956                row_id,
9957                row,
9958            } = &record.op
9959            else {
9960                continue;
9961            };
9962            if row.len() > CDC_MAX_INLINE_PAYLOAD_BYTES {
9963                return Err(MongrelError::ResourceLimitExceeded {
9964                    resource: "CDC before-image bytes",
9965                    requested: row.len(),
9966                    limit: CDC_MAX_INLINE_PAYLOAD_BYTES,
9967                });
9968            }
9969            let before: crate::memtable::Row = bincode::deserialize(row)?;
9970            if before_images.len() >= CDC_MAX_ROWS {
9971                return Err(MongrelError::ResourceLimitExceeded {
9972                    resource: "CDC before-image rows",
9973                    requested: before_images.len().saturating_add(1),
9974                    limit: CDC_MAX_ROWS,
9975                });
9976            }
9977            charge_cdc_bytes(
9978                &mut retained_bytes,
9979                cdc_row_storage_bytes(&before),
9980                "CDC retained bytes",
9981            )?;
9982            before_images.insert((record.txn_id, *table_id, row_id.0), before);
9983        }
9984        let mut operation_indices: HashMap<u64, u32> = HashMap::new();
9985        let mut events = Vec::new();
9986        let mut decoded_rows = before_images.len();
9987        for (record_index, record) in records.iter().enumerate() {
9988            if record_index % 256 == 0 {
9989                control.checkpoint()?;
9990            }
9991            let Some((commit_epoch, _)) = commits.get(&record.txn_id) else {
9992                continue;
9993            };
9994            let event = match &record.op {
9995                Op::Put { table_id, rows } => {
9996                    if rows.len() > CDC_MAX_INLINE_PAYLOAD_BYTES {
9997                        return Err(MongrelError::ResourceLimitExceeded {
9998                            resource: "CDC inline row bytes",
9999                            requested: rows.len(),
10000                            limit: CDC_MAX_INLINE_PAYLOAD_BYTES,
10001                        });
10002                    }
10003                    let rows: Vec<crate::memtable::Row> = bincode::deserialize(rows)?;
10004                    decoded_rows = decoded_rows.saturating_add(rows.len());
10005                    if decoded_rows > CDC_MAX_ROWS {
10006                        return Err(MongrelError::ResourceLimitExceeded {
10007                            resource: "CDC decoded rows",
10008                            requested: decoded_rows,
10009                            limit: CDC_MAX_ROWS,
10010                        });
10011                    }
10012                    let event_bytes = cdc_rows_json_bytes(&rows).saturating_add(512);
10013                    let mut peak_bytes = retained_bytes;
10014                    charge_cdc_bytes(&mut peak_bytes, event_bytes, "CDC retained event bytes")?;
10015                    let data = serde_json::to_value(rows)
10016                        .map_err(|error| MongrelError::Other(format!("CDC JSON: {error}")))?;
10017                    Some((*table_id, "put", data, event_bytes))
10018                }
10019                Op::Delete { table_id, row_ids } => {
10020                    let before = row_ids
10021                        .iter()
10022                        .filter_map(|row_id| {
10023                            before_images
10024                                .get(&(record.txn_id, *table_id, row_id.0))
10025                                .cloned()
10026                        })
10027                        .collect::<Vec<_>>();
10028                    let event_bytes = cdc_rows_json_bytes(&before)
10029                        .saturating_add(
10030                            row_ids
10031                                .len()
10032                                .saturating_mul(std::mem::size_of::<serde_json::Value>()),
10033                        )
10034                        .saturating_add(512);
10035                    let mut peak_bytes = retained_bytes;
10036                    charge_cdc_bytes(&mut peak_bytes, event_bytes, "CDC retained event bytes")?;
10037                    Some((
10038                        *table_id,
10039                        "delete",
10040                        serde_json::json!({
10041                            "row_ids": row_ids.iter().map(|row_id| row_id.0).collect::<Vec<_>>(),
10042                            "before": before,
10043                        }),
10044                        event_bytes,
10045                    ))
10046                }
10047                Op::TruncateTable { table_id } => {
10048                    Some((*table_id, "truncate", serde_json::Value::Null, 512))
10049                }
10050                _ => None,
10051            };
10052            if let Some((table_id, op, data, event_bytes)) = event {
10053                let index = operation_indices.entry(record.txn_id).or_insert(0);
10054                let event_position = (*commit_epoch, *index);
10055                *index = index.saturating_add(1);
10056                if resume.is_some_and(|position| event_position <= position) {
10057                    continue;
10058                }
10059                if events.len() >= CDC_MAX_EVENTS {
10060                    return Err(MongrelError::ResourceLimitExceeded {
10061                        resource: "CDC events",
10062                        requested: events.len().saturating_add(1),
10063                        limit: CDC_MAX_EVENTS,
10064                    });
10065                }
10066                charge_cdc_bytes(&mut retained_bytes, event_bytes, "CDC retained event bytes")?;
10067                events.push(ChangeEvent {
10068                    id: Some(format!("{}:{}", event_position.0, event_position.1)),
10069                    channel: "changes".into(),
10070                    table_id: Some(table_id),
10071                    table: table_names.get(&table_id).cloned().unwrap_or_default(),
10072                    op: op.into(),
10073                    epoch: *commit_epoch,
10074                    txn_id: Some(record.txn_id),
10075                    message: None,
10076                    data: Some(data),
10077                });
10078            }
10079            if let Op::TxnCommit { added_runs, .. } = &record.op {
10080                for run in added_runs {
10081                    control.checkpoint()?;
10082                    let index = operation_indices.entry(record.txn_id).or_insert(0);
10083                    let event_position = (*commit_epoch, *index);
10084                    *index = index.saturating_add(1);
10085                    if resume.is_some_and(|position| event_position <= position) {
10086                        continue;
10087                    }
10088                    let mut rows = if let Some(payloads) =
10089                        spilled_payloads.get(&(record.txn_id, run.table_id))
10090                    {
10091                        let mut rows = Vec::new();
10092                        for payload in payloads {
10093                            control.checkpoint()?;
10094                            if payload.len() > CDC_MAX_INLINE_PAYLOAD_BYTES {
10095                                return Err(MongrelError::ResourceLimitExceeded {
10096                                    resource: "CDC spilled row bytes",
10097                                    requested: payload.len(),
10098                                    limit: CDC_MAX_INLINE_PAYLOAD_BYTES,
10099                                });
10100                            }
10101                            let chunk: Vec<crate::memtable::Row> = bincode::deserialize(payload)?;
10102                            if decoded_rows
10103                                .saturating_add(rows.len())
10104                                .saturating_add(chunk.len())
10105                                > CDC_MAX_ROWS
10106                            {
10107                                return Err(MongrelError::ResourceLimitExceeded {
10108                                    resource: "CDC decoded rows",
10109                                    requested: decoded_rows
10110                                        .saturating_add(rows.len())
10111                                        .saturating_add(chunk.len()),
10112                                    limit: CDC_MAX_ROWS,
10113                                });
10114                            }
10115                            rows.extend(chunk);
10116                        }
10117                        rows
10118                    } else {
10119                        let Some(handle) = self.tables.read().get(&run.table_id).cloned() else {
10120                            return Ok(CdcBatch {
10121                                events: Vec::new(),
10122                                current_epoch,
10123                                earliest_epoch,
10124                                gap: true,
10125                            });
10126                        };
10127                        let table = handle.lock();
10128                        let mut reader = match table.open_reader(run.run_id) {
10129                            Ok(reader) => reader,
10130                            Err(_) => {
10131                                return Ok(CdcBatch {
10132                                    events: Vec::new(),
10133                                    current_epoch,
10134                                    earliest_epoch,
10135                                    gap: true,
10136                                })
10137                            }
10138                        };
10139                        let remaining = CDC_MAX_ROWS.saturating_sub(decoded_rows);
10140                        let rows = reader.all_rows_controlled(control, remaining)?;
10141                        drop(reader);
10142                        drop(table);
10143                        rows
10144                    };
10145                    for row in &mut rows {
10146                        row.committed_epoch = Epoch(*commit_epoch);
10147                    }
10148                    decoded_rows = decoded_rows.saturating_add(rows.len());
10149                    let event_bytes = cdc_rows_json_bytes(&rows).saturating_add(768);
10150                    charge_cdc_bytes(&mut retained_bytes, event_bytes, "CDC retained event bytes")?;
10151                    if events.len() >= CDC_MAX_EVENTS {
10152                        return Err(MongrelError::ResourceLimitExceeded {
10153                            resource: "CDC events",
10154                            requested: events.len().saturating_add(1),
10155                            limit: CDC_MAX_EVENTS,
10156                        });
10157                    }
10158                    events.push(ChangeEvent {
10159                        id: Some(format!("{}:{}", event_position.0, event_position.1)),
10160                        channel: "changes".into(),
10161                        table_id: Some(run.table_id),
10162                        table: table_names.get(&run.table_id).cloned().unwrap_or_default(),
10163                        op: "put_run".into(),
10164                        epoch: *commit_epoch,
10165                        txn_id: Some(record.txn_id),
10166                        message: None,
10167                        data: Some(serde_json::json!({
10168                            "run_id": run.run_id.to_string(),
10169                            "row_count": run.row_count,
10170                            "min_row_id": run.min_row_id,
10171                            "max_row_id": run.max_row_id,
10172                            "rows": rows,
10173                        })),
10174                    });
10175                }
10176            }
10177        }
10178        control.checkpoint()?;
10179        Ok(CdcBatch {
10180            events,
10181            current_epoch,
10182            earliest_epoch,
10183            gap: false,
10184        })
10185    }
10186
10187    /// Publish a notification message on a named channel. Reaches all active
10188    /// subscribers (daemon `/events`, application listeners).
10189    pub fn notify(&self, channel: &str, message: Option<String>) {
10190        let _ = self.notify.send(ChangeEvent {
10191            id: None,
10192            channel: channel.to_string(),
10193            table_id: None,
10194            table: String::new(),
10195            op: "notify".into(),
10196            epoch: self.epoch.visible().0,
10197            txn_id: None,
10198            message,
10199            data: None,
10200        });
10201    }
10202
10203    pub fn call_procedure(
10204        &self,
10205        name: &str,
10206        args: HashMap<String, crate::Value>,
10207    ) -> Result<ProcedureCallResult> {
10208        self.call_procedure_as(name, args, None)
10209    }
10210
10211    pub fn call_procedure_as(
10212        &self,
10213        name: &str,
10214        args: HashMap<String, crate::Value>,
10215        principal: Option<&crate::auth::Principal>,
10216    ) -> Result<ProcedureCallResult> {
10217        let control = crate::ExecutionControl::new(None);
10218        self.call_procedure_as_controlled(name, args, principal, &control, || true)
10219    }
10220
10221    /// Execute only the exact procedure revision previously authorized by the
10222    /// caller. A dropped or replaced definition fails closed.
10223    #[doc(hidden)]
10224    pub fn call_procedure_as_bound(
10225        &self,
10226        expected: &StoredProcedure,
10227        args: HashMap<String, crate::Value>,
10228        principal: Option<&crate::auth::Principal>,
10229    ) -> Result<ProcedureCallResult> {
10230        self.require_for(principal, &crate::auth::Permission::All)?;
10231        let procedure = self.procedure(&expected.name).ok_or_else(|| {
10232            MongrelError::NotFound(format!("procedure {:?} not found", expected.name))
10233        })?;
10234        if &procedure != expected {
10235            return Err(MongrelError::Conflict(format!(
10236                "procedure {:?} changed after request authorization",
10237                expected.name
10238            )));
10239        }
10240        let control = crate::ExecutionControl::new(None);
10241        self.execute_procedure_as_controlled(procedure, args, principal, &control, || true)
10242    }
10243
10244    /// Execute a procedure with cooperative cancellation during preparation.
10245    /// `before_commit` runs after every procedure step has succeeded and
10246    /// immediately before a write procedure commits. Returning `false` aborts
10247    /// the transaction without publishing it.
10248    #[doc(hidden)]
10249    pub fn call_procedure_as_controlled<F>(
10250        &self,
10251        name: &str,
10252        args: HashMap<String, crate::Value>,
10253        principal: Option<&crate::auth::Principal>,
10254        control: &crate::ExecutionControl,
10255        before_commit: F,
10256    ) -> Result<ProcedureCallResult>
10257    where
10258        F: FnOnce() -> bool,
10259    {
10260        // v1 requires ALL to call procedures on a require_auth database; a
10261        // finer SECURITY DEFINER-style marker is a future extension (spec §9
10262        // decision 1).
10263        self.require_for(principal, &crate::auth::Permission::All)?;
10264        let procedure = self
10265            .procedure(name)
10266            .ok_or_else(|| MongrelError::NotFound(format!("procedure {name:?} not found")))?;
10267        self.execute_procedure_as_controlled(procedure, args, principal, control, before_commit)
10268    }
10269
10270    fn execute_procedure_as_controlled<F>(
10271        &self,
10272        procedure: StoredProcedure,
10273        args: HashMap<String, crate::Value>,
10274        principal: Option<&crate::auth::Principal>,
10275        control: &crate::ExecutionControl,
10276        before_commit: F,
10277    ) -> Result<ProcedureCallResult>
10278    where
10279        F: FnOnce() -> bool,
10280    {
10281        let args = bind_procedure_args(&procedure, args)?;
10282        let has_writes = procedure.body.steps.iter().any(ProcedureStep::is_write);
10283        let mut outputs: HashMap<String, ProcedureCallOutput> = HashMap::new();
10284        if has_writes {
10285            let mut tx = self.begin_as(principal.cloned());
10286            let run = (|| {
10287                for (step_index, step) in procedure.body.steps.iter().enumerate() {
10288                    if step_index % 256 == 0 {
10289                        control.checkpoint()?;
10290                    }
10291                    let output = self.execute_procedure_step(
10292                        step,
10293                        &args,
10294                        &outputs,
10295                        Some(&mut tx),
10296                        principal,
10297                        Some(control),
10298                    )?;
10299                    outputs.insert(step.id().to_string(), output);
10300                }
10301                control.checkpoint()?;
10302                eval_return_output(&procedure.body.return_value, &args, &outputs)
10303            })();
10304            match run {
10305                Ok(output) => {
10306                    control.checkpoint()?;
10307                    if !before_commit() {
10308                        tx.rollback();
10309                        return Err(MongrelError::Cancelled);
10310                    }
10311                    let epoch = tx.commit()?.0;
10312                    Ok(ProcedureCallResult {
10313                        epoch: Some(epoch),
10314                        output,
10315                    })
10316                }
10317                Err(e) => {
10318                    tx.rollback();
10319                    Err(e)
10320                }
10321            }
10322        } else {
10323            for (step_index, step) in procedure.body.steps.iter().enumerate() {
10324                if step_index % 256 == 0 {
10325                    control.checkpoint()?;
10326                }
10327                let output = self.execute_procedure_step(
10328                    step,
10329                    &args,
10330                    &outputs,
10331                    None,
10332                    principal,
10333                    Some(control),
10334                )?;
10335                outputs.insert(step.id().to_string(), output);
10336            }
10337            control.checkpoint()?;
10338            Ok(ProcedureCallResult {
10339                epoch: None,
10340                output: eval_return_output(&procedure.body.return_value, &args, &outputs)?,
10341            })
10342        }
10343    }
10344
10345    fn execute_procedure_step(
10346        &self,
10347        step: &ProcedureStep,
10348        args: &HashMap<String, crate::Value>,
10349        outputs: &HashMap<String, ProcedureCallOutput>,
10350        tx: Option<&mut crate::txn::Transaction<'_>>,
10351        principal: Option<&crate::auth::Principal>,
10352        control: Option<&crate::ExecutionControl>,
10353    ) -> Result<ProcedureCallOutput> {
10354        if let Some(control) = control {
10355            control.checkpoint()?;
10356        }
10357        match step {
10358            ProcedureStep::NativeQuery {
10359                table,
10360                conditions,
10361                projection,
10362                limit,
10363                ..
10364            } => {
10365                let mut q = crate::Query::new();
10366                for condition in conditions {
10367                    q = q.and(eval_condition(condition, args, outputs)?);
10368                }
10369                let fallback_control = crate::ExecutionControl::new(None);
10370                let query_control = control.unwrap_or(&fallback_control);
10371                let mut rows = self.query_for_principal_controlled(
10372                    table,
10373                    &q,
10374                    projection.as_deref(),
10375                    principal,
10376                    false,
10377                    query_control,
10378                )?;
10379                if let Some(limit) = limit {
10380                    rows.truncate(*limit);
10381                }
10382                let mut output = Vec::with_capacity(rows.len());
10383                for (row_index, row) in rows.into_iter().enumerate() {
10384                    if row_index % 256 == 0 {
10385                        if let Some(control) = control {
10386                            control.checkpoint()?;
10387                        }
10388                    }
10389                    output.push(ProcedureCallRow {
10390                        row_id: Some(row.row_id),
10391                        columns: row.columns,
10392                    });
10393                }
10394                Ok(ProcedureCallOutput::Rows(output))
10395            }
10396            ProcedureStep::Put {
10397                table,
10398                cells,
10399                returning,
10400                ..
10401            } => {
10402                let tx = tx.ok_or_else(|| {
10403                    MongrelError::InvalidArgument(
10404                        "write procedure step requires a transaction".into(),
10405                    )
10406                })?;
10407                let cells = eval_cells(cells, args, outputs)?;
10408                if *returning {
10409                    let out = tx.put_returning(table, cells)?;
10410                    Ok(ProcedureCallOutput::Row(ProcedureCallRow {
10411                        row_id: None,
10412                        columns: out.row.columns.into_iter().collect(),
10413                    }))
10414                } else {
10415                    tx.put(table, cells)?;
10416                    Ok(ProcedureCallOutput::Null)
10417                }
10418            }
10419            ProcedureStep::Upsert {
10420                table,
10421                cells,
10422                update_cells,
10423                returning,
10424                ..
10425            } => {
10426                let tx = tx.ok_or_else(|| {
10427                    MongrelError::InvalidArgument(
10428                        "write procedure step requires a transaction".into(),
10429                    )
10430                })?;
10431                let cells = eval_cells(cells, args, outputs)?;
10432                let action = match update_cells {
10433                    Some(update_cells) => {
10434                        crate::UpsertAction::DoUpdate(eval_cells(update_cells, args, outputs)?)
10435                    }
10436                    None => crate::UpsertAction::DoNothing,
10437                };
10438                let out = tx.upsert(table, cells, action)?;
10439                if *returning {
10440                    Ok(ProcedureCallOutput::Row(ProcedureCallRow {
10441                        row_id: None,
10442                        columns: out.row.columns.into_iter().collect(),
10443                    }))
10444                } else {
10445                    Ok(ProcedureCallOutput::Null)
10446                }
10447            }
10448            ProcedureStep::DeleteByPk { table, pk, .. } => {
10449                let tx = tx.ok_or_else(|| {
10450                    MongrelError::InvalidArgument(
10451                        "write procedure step requires a transaction".into(),
10452                    )
10453                })?;
10454                let pk = eval_value(pk, args, outputs)?;
10455                let handle = self.table(table)?;
10456                let row_id = handle.lock().lookup_pk(&pk.encode_key()).ok_or_else(|| {
10457                    MongrelError::NotFound("procedure delete_by_pk target not found".into())
10458                })?;
10459                tx.delete(table, row_id)?;
10460                Ok(ProcedureCallOutput::Scalar(crate::Value::Bool(true)))
10461            }
10462            ProcedureStep::DeleteRows { .. } => Err(MongrelError::InvalidArgument(
10463                "DeleteRows procedure step is not supported by the core executor yet".into(),
10464            )),
10465            ProcedureStep::SqlQuery { .. } => Err(MongrelError::InvalidArgument(
10466                "SqlQuery procedure step must be executed by mongreldb-query".into(),
10467            )),
10468        }
10469    }
10470
10471    fn validate_procedure_references(&self, procedure: &StoredProcedure) -> Result<()> {
10472        let cat = self.catalog.read();
10473        for step in &procedure.body.steps {
10474            let Some(table_name) = step.table() else {
10475                continue;
10476            };
10477            let schema = &cat
10478                .live(table_name)
10479                .ok_or_else(|| {
10480                    MongrelError::InvalidArgument(format!(
10481                        "procedure {:?} references unknown table {table_name:?}",
10482                        procedure.name
10483                    ))
10484                })?
10485                .schema;
10486            match step {
10487                ProcedureStep::NativeQuery {
10488                    conditions,
10489                    projection,
10490                    ..
10491                } => {
10492                    for condition in conditions {
10493                        validate_condition_columns(condition, schema)?;
10494                    }
10495                    if let Some(projection) = projection {
10496                        for id in projection {
10497                            validate_column_id(*id, schema)?;
10498                        }
10499                    }
10500                }
10501                ProcedureStep::Put { cells, .. } => {
10502                    for cell in cells {
10503                        validate_column_id(cell.column_id, schema)?;
10504                    }
10505                }
10506                ProcedureStep::Upsert {
10507                    cells,
10508                    update_cells,
10509                    ..
10510                } => {
10511                    for cell in cells {
10512                        validate_column_id(cell.column_id, schema)?;
10513                    }
10514                    if let Some(update_cells) = update_cells {
10515                        for cell in update_cells {
10516                            validate_column_id(cell.column_id, schema)?;
10517                        }
10518                    }
10519                }
10520                ProcedureStep::DeleteByPk { .. } => {
10521                    if schema.primary_key().is_none() {
10522                        return Err(MongrelError::InvalidArgument(format!(
10523                            "procedure {:?} references DeleteByPk on table {table_name:?} without a primary key",
10524                            procedure.name
10525                        )));
10526                    }
10527                }
10528                ProcedureStep::DeleteRows { .. } | ProcedureStep::SqlQuery { .. } => {}
10529            }
10530        }
10531        Ok(())
10532    }
10533
10534    fn validate_trigger_references(&self, trigger: &StoredTrigger) -> Result<()> {
10535        let cat = self.catalog.read();
10536        let target_schema = match &trigger.target {
10537            TriggerTarget::Table(target_name) => cat
10538                .live(target_name)
10539                .ok_or_else(|| {
10540                    MongrelError::InvalidArgument(format!(
10541                        "trigger {:?} references unknown target table {target_name:?}",
10542                        trigger.name
10543                    ))
10544                })?
10545                .schema
10546                .clone(),
10547            TriggerTarget::View(_) => Schema {
10548                columns: trigger.target_columns.clone(),
10549                ..Schema::default()
10550            },
10551        };
10552        for col in &trigger.update_of {
10553            if target_schema.column(col).is_none() {
10554                return Err(MongrelError::InvalidArgument(format!(
10555                    "trigger {:?} UPDATE OF references unknown column {col:?}",
10556                    trigger.name
10557                )));
10558            }
10559        }
10560        if let Some(expr) = &trigger.when {
10561            validate_trigger_expr(expr, &target_schema, trigger.event)?;
10562        }
10563        let mut select_schemas: HashMap<String, &Schema> = HashMap::new();
10564        for step in &trigger.program.steps {
10565            if matches!(step, TriggerStep::SetNew { .. }) && trigger.timing != TriggerTiming::Before
10566            {
10567                return Err(MongrelError::InvalidArgument(
10568                    "SetNew trigger steps are only valid in BEFORE triggers".into(),
10569                ));
10570            }
10571            validate_trigger_step(
10572                step,
10573                &cat,
10574                &target_schema,
10575                trigger.event,
10576                &mut select_schemas,
10577            )?;
10578        }
10579        Ok(())
10580    }
10581
10582    /// Begin a new transaction reading at the current visible epoch.
10583    pub fn begin(&self) -> crate::txn::Transaction<'_> {
10584        self.begin_with_isolation(crate::txn::IsolationLevel::default())
10585    }
10586
10587    fn transaction_principal_snapshot(&self) -> (Option<crate::auth::Principal>, bool) {
10588        let principal = self.principal.read().clone();
10589        let catalog_bound = principal
10590            .as_ref()
10591            .is_some_and(|principal| principal.user_id != 0);
10592        (principal, catalog_bound)
10593    }
10594
10595    pub fn begin_as(
10596        &self,
10597        principal: Option<crate::auth::Principal>,
10598    ) -> crate::txn::Transaction<'_> {
10599        let catalog_bound = principal
10600            .as_ref()
10601            .is_some_and(|principal| principal.user_id != 0);
10602        let txn_id = self.alloc_txn_id();
10603        let read = self.visible_snapshot();
10604        crate::txn::Transaction::new(self, txn_id, read, crate::txn::IsolationLevel::default())
10605            .with_principal(principal, catalog_bound)
10606    }
10607
10608    /// Begin a transaction with a specific isolation level.
10609    pub fn begin_with_isolation(
10610        &self,
10611        level: crate::txn::IsolationLevel,
10612    ) -> crate::txn::Transaction<'_> {
10613        let txn_id = self.alloc_txn_id();
10614        // Every level pins the current visible epoch + HLC at begin; ReadCommitted
10615        // re-pins per statement inside the transaction (S1B-002 / P0.5).
10616        let read = self.visible_snapshot();
10617        let (principal, catalog_bound) = self.transaction_principal_snapshot();
10618        crate::txn::Transaction::new(self, txn_id, read, level)
10619            .with_principal(principal, catalog_bound)
10620    }
10621
10622    /// Begin a transaction whose trigger programs may route external-table DML
10623    /// through an application/query-layer module bridge.
10624    pub fn begin_with_external_trigger_bridge<'a>(
10625        &'a self,
10626        bridge: &'a dyn ExternalTriggerBridge,
10627    ) -> crate::txn::Transaction<'a> {
10628        let txn_id = self.alloc_txn_id();
10629        let read = self.visible_snapshot();
10630        let (principal, catalog_bound) = self.transaction_principal_snapshot();
10631        crate::txn::Transaction::new(self, txn_id, read, crate::txn::IsolationLevel::default())
10632            .with_external_trigger_bridge(bridge)
10633            .with_principal(principal, catalog_bound)
10634    }
10635
10636    pub fn begin_with_external_trigger_bridge_as<'a>(
10637        &'a self,
10638        bridge: &'a dyn ExternalTriggerBridge,
10639        principal: Option<crate::auth::Principal>,
10640    ) -> crate::txn::Transaction<'a> {
10641        let catalog_bound = principal
10642            .as_ref()
10643            .is_some_and(|principal| principal.user_id != 0);
10644        let txn_id = self.alloc_txn_id();
10645        let read = self.visible_snapshot();
10646        crate::txn::Transaction::new(self, txn_id, read, crate::txn::IsolationLevel::default())
10647            .with_external_trigger_bridge(bridge)
10648            .with_principal(principal, catalog_bound)
10649    }
10650
10651    /// Run `f` in a transaction; commit on `Ok`, rollback on `Err`.
10652    pub fn transaction<T>(
10653        &self,
10654        f: impl FnOnce(&mut crate::txn::Transaction) -> Result<T>,
10655    ) -> Result<T> {
10656        let mut tx = self.begin();
10657        match f(&mut tx) {
10658            Ok(out) => {
10659                tx.commit()?;
10660                Ok(out)
10661            }
10662            Err(e) => {
10663                tx.rollback();
10664                Err(e)
10665            }
10666        }
10667    }
10668
10669    pub fn transaction_with_row_ids<T>(
10670        &self,
10671        f: impl FnOnce(&mut crate::txn::Transaction) -> Result<T>,
10672    ) -> Result<(T, Vec<RowId>)> {
10673        let mut tx = self.begin();
10674        match f(&mut tx) {
10675            Ok(output) => {
10676                let (_, row_ids) = tx.commit_with_row_ids()?;
10677                Ok((output, row_ids))
10678            }
10679            Err(error) => {
10680                tx.rollback();
10681                Err(error)
10682            }
10683        }
10684    }
10685
10686    pub fn transaction_for_current_principal<T>(
10687        &self,
10688        f: impl FnOnce(&mut crate::txn::Transaction) -> Result<T>,
10689    ) -> Result<T> {
10690        if self.principal.read().is_some() {
10691            self.refresh_principal()?;
10692        }
10693        let mut transaction = self.begin_as(self.principal.read().clone());
10694        match f(&mut transaction) {
10695            Ok(output) => {
10696                transaction.commit()?;
10697                Ok(output)
10698            }
10699            Err(error) => {
10700                transaction.rollback();
10701                Err(error)
10702            }
10703        }
10704    }
10705
10706    pub fn transaction_for_current_principal_with_epoch<T>(
10707        &self,
10708        f: impl FnOnce(&mut crate::txn::Transaction) -> Result<T>,
10709    ) -> Result<(Epoch, T)> {
10710        if self.principal.read().is_some() {
10711            self.refresh_principal()?;
10712        }
10713        let mut transaction = self.begin_as(self.principal.read().clone());
10714        match f(&mut transaction) {
10715            Ok(output) => {
10716                let epoch = transaction.commit()?;
10717                Ok((epoch, output))
10718            }
10719            Err(error) => {
10720                transaction.rollback();
10721                Err(error)
10722            }
10723        }
10724    }
10725
10726    pub fn transaction_with_row_ids_for_current_principal<T>(
10727        &self,
10728        f: impl FnOnce(&mut crate::txn::Transaction) -> Result<T>,
10729    ) -> Result<(T, Vec<RowId>)> {
10730        if self.principal.read().is_some() {
10731            self.refresh_principal()?;
10732        }
10733        let mut transaction = self.begin_as(self.principal.read().clone());
10734        match f(&mut transaction) {
10735            Ok(output) => {
10736                let (_, row_ids) = transaction.commit_with_row_ids()?;
10737                Ok((output, row_ids))
10738            }
10739            Err(error) => {
10740                transaction.rollback();
10741                Err(error)
10742            }
10743        }
10744    }
10745
10746    /// Run `f` in a transaction with an external-trigger bridge; commit on
10747    /// `Ok`, rollback on `Err`.
10748    pub fn transaction_with_external_trigger_bridge<'a, T>(
10749        &'a self,
10750        bridge: &'a dyn ExternalTriggerBridge,
10751        f: impl FnOnce(&mut crate::txn::Transaction) -> Result<T>,
10752    ) -> Result<T> {
10753        let mut tx = self.begin_with_external_trigger_bridge(bridge);
10754        match f(&mut tx) {
10755            Ok(out) => {
10756                tx.commit()?;
10757                Ok(out)
10758            }
10759            Err(e) => {
10760                tx.rollback();
10761                Err(e)
10762            }
10763        }
10764    }
10765
10766    pub fn transaction_with_external_trigger_bridge_as<'a, T>(
10767        &'a self,
10768        bridge: &'a dyn ExternalTriggerBridge,
10769        principal: Option<crate::auth::Principal>,
10770        f: impl FnOnce(&mut crate::txn::Transaction) -> Result<T>,
10771    ) -> Result<T> {
10772        let mut tx = self.begin_with_external_trigger_bridge_as(bridge, principal);
10773        match f(&mut tx) {
10774            Ok(output) => {
10775                tx.commit()?;
10776                Ok(output)
10777            }
10778            Err(error) => {
10779                tx.rollback();
10780                Err(error)
10781            }
10782        }
10783    }
10784
10785    /// Register a txn in `ActiveTxns` (spec §9.2, review fix #12). Called from
10786    /// `Transaction::new` so registration happens **before** any read.
10787    pub(crate) fn register_active(&self, epoch: Epoch) -> crate::txn::ActiveTxnGuard<'_> {
10788        self.active_txns.register(epoch)
10789    }
10790
10791    fn fill_auto_increment_for_staging(
10792        &self,
10793        txn_id: u64,
10794        staging: &mut [(u64, crate::txn::Staged)],
10795        control: Option<&crate::ExecutionControl>,
10796    ) -> Result<()> {
10797        let mut puts_by_table: HashMap<u64, Vec<usize>> = HashMap::new();
10798        for (index, (table_id, staged)) in staging.iter().enumerate() {
10799            commit_prepare_checkpoint(control, index)?;
10800            if matches!(staged, crate::txn::Staged::Put(_)) {
10801                puts_by_table.entry(*table_id).or_default().push(index);
10802            }
10803        }
10804
10805        // S1B-003: sequence allocation serializes on one Exclusive barrier per
10806        // staged table whose puts actually ALLOCATE an auto-increment value
10807        // (absent/Null column — explicit values only advance the counter and
10808        // must not serialize), held until the transaction ends so allocated
10809        // values map monotonically onto commit order. The barrier is acquired
10810        // before the table lock (never the reverse).
10811        {
10812            let mut barrier_tables: Vec<u64> = puts_by_table
10813                .iter()
10814                .filter(|(table_id, indexes)| {
10815                    indexes.iter().any(|index| {
10816                        matches!(
10817                            &staging[*index].1,
10818                            crate::txn::Staged::Put(cells)
10819                                if self.table_auto_inc_would_allocate(**table_id, cells)
10820                        )
10821                    })
10822                })
10823                .map(|(table_id, _)| *table_id)
10824                .collect();
10825            barrier_tables.sort_unstable();
10826            for table_id in barrier_tables {
10827                self.acquire_txn_lock(
10828                    txn_id,
10829                    crate::locks::LockKey::sequence_barrier(&format!("auto_inc:{table_id}")),
10830                    crate::locks::LockMode::Exclusive,
10831                    control,
10832                )?;
10833            }
10834        }
10835
10836        let tables = self.tables.read();
10837        for (table_index, (table_id, indexes)) in puts_by_table.into_iter().enumerate() {
10838            commit_prepare_checkpoint(control, table_index)?;
10839            if let Some(handle) = tables.get(&table_id) {
10840                #[cfg(test)]
10841                AUTO_INCREMENT_TABLE_LOCKS.with(|count| count.set(count.get() + 1));
10842                let mut t = handle.lock();
10843                for (fill_index, index) in indexes.into_iter().enumerate() {
10844                    commit_prepare_checkpoint(control, fill_index)?;
10845                    if let crate::txn::Staged::Put(cells) = &mut staging[index].1 {
10846                        t.fill_auto_inc(cells)?;
10847                    }
10848                }
10849            }
10850        }
10851        Ok(())
10852    }
10853
10854    fn expand_table_triggers(
10855        &self,
10856        txn_id: u64,
10857        staging: &mut Vec<(u64, crate::txn::Staged)>,
10858        read_epoch: Epoch,
10859        external_trigger_bridge: Option<&dyn ExternalTriggerBridge>,
10860        external_states: &mut Vec<(String, Vec<u8>)>,
10861        control: Option<&crate::ExecutionControl>,
10862    ) -> Result<()> {
10863        commit_prepare_checkpoint(control, 0)?;
10864        let mut external_writes = Vec::new();
10865        let config = self.trigger_config();
10866        if config.recursive_triggers {
10867            let chunk = std::mem::take(staging);
10868            let stacks = vec![Vec::new(); chunk.len()];
10869            *staging = self.expand_trigger_chunk(
10870                txn_id,
10871                chunk,
10872                stacks,
10873                read_epoch,
10874                0,
10875                config.max_depth,
10876                &mut external_writes,
10877                &config,
10878                control,
10879            )?;
10880            self.apply_external_trigger_writes(
10881                external_writes,
10882                external_trigger_bridge,
10883                external_states,
10884                staging,
10885                control,
10886            )?;
10887            return Ok(());
10888        }
10889
10890        let mut expansion =
10891            self.expand_table_triggers_once(staging, read_epoch, None, &config, control)?;
10892        if !expansion.before.is_empty() {
10893            let mut final_staging = expansion.before;
10894            final_staging.extend(filter_ignored_staging(
10895                std::mem::take(staging),
10896                &expansion.ignored_indices,
10897            ));
10898            *staging = final_staging;
10899        } else if !expansion.ignored_indices.is_empty() {
10900            *staging = filter_ignored_staging(std::mem::take(staging), &expansion.ignored_indices);
10901        }
10902        staging.append(&mut expansion.after);
10903        external_writes.append(&mut expansion.before_external);
10904        external_writes.append(&mut expansion.after_external);
10905        self.apply_external_trigger_writes(
10906            external_writes,
10907            external_trigger_bridge,
10908            external_states,
10909            staging,
10910            control,
10911        )?;
10912        Ok(())
10913    }
10914
10915    #[allow(clippy::too_many_arguments)]
10916    fn expand_trigger_chunk(
10917        &self,
10918        txn_id: u64,
10919        mut chunk: Vec<(u64, crate::txn::Staged)>,
10920        stacks: Vec<Vec<String>>,
10921        read_epoch: Epoch,
10922        depth: u32,
10923        max_depth: u32,
10924        external_writes: &mut Vec<ExternalTriggerWrite>,
10925        config: &TriggerConfig,
10926        control: Option<&crate::ExecutionControl>,
10927    ) -> Result<Vec<(u64, crate::txn::Staged)>> {
10928        if chunk.is_empty() {
10929            return Ok(Vec::new());
10930        }
10931        commit_prepare_checkpoint(control, 0)?;
10932        self.fill_auto_increment_for_staging(txn_id, &mut chunk, control)?;
10933        let expansion = self.expand_table_triggers_once(
10934            &mut chunk,
10935            read_epoch,
10936            Some(&stacks),
10937            config,
10938            control,
10939        )?;
10940        if depth >= max_depth && (!expansion.before.is_empty() || !expansion.after.is_empty()) {
10941            let stack = expansion
10942                .before_stacks
10943                .first()
10944                .or_else(|| expansion.after_stacks.first())
10945                .cloned()
10946                .unwrap_or_default();
10947            return Err(MongrelError::TriggerValidation(format!(
10948                "trigger recursion exceeded max depth {max_depth}; trigger stack: {}",
10949                Self::format_trigger_stack(&stack)
10950            )));
10951        }
10952
10953        let mut out = Vec::new();
10954        external_writes.extend(expansion.before_external);
10955        out.extend(self.expand_trigger_chunk(
10956            txn_id,
10957            expansion.before,
10958            expansion.before_stacks,
10959            read_epoch,
10960            depth + 1,
10961            max_depth,
10962            external_writes,
10963            config,
10964            control,
10965        )?);
10966        out.extend(filter_ignored_staging(chunk, &expansion.ignored_indices));
10967        external_writes.extend(expansion.after_external);
10968        out.extend(self.expand_trigger_chunk(
10969            txn_id,
10970            expansion.after,
10971            expansion.after_stacks,
10972            read_epoch,
10973            depth + 1,
10974            max_depth,
10975            external_writes,
10976            config,
10977            control,
10978        )?);
10979        Ok(out)
10980    }
10981
10982    fn apply_external_trigger_writes(
10983        &self,
10984        writes: Vec<ExternalTriggerWrite>,
10985        bridge: Option<&dyn ExternalTriggerBridge>,
10986        external_states: &mut Vec<(String, Vec<u8>)>,
10987        staging: &mut Vec<(u64, crate::txn::Staged)>,
10988        control: Option<&crate::ExecutionControl>,
10989    ) -> Result<()> {
10990        if writes.is_empty() {
10991            return Ok(());
10992        }
10993        let bridge = bridge.ok_or_else(|| {
10994            MongrelError::TriggerValidation(
10995                "trigger program wrote an external table, but this transaction has no external trigger bridge".into(),
10996            )
10997        })?;
10998        for (write_index, write) in writes.into_iter().enumerate() {
10999            commit_prepare_checkpoint(control, write_index)?;
11000            let table = write.table().to_string();
11001            let entry = self.external_table(&table).ok_or_else(|| {
11002                MongrelError::NotFound(format!("external table {table:?} not found"))
11003            })?;
11004            let base_state = current_external_state_bytes(&self.root, external_states, &table)?;
11005            let result = bridge.apply_trigger_external_write(&entry, base_state, write)?;
11006            external_states.push((table, result.state));
11007            for (base_index, base_write) in result.base_writes.into_iter().enumerate() {
11008                commit_prepare_checkpoint(control, base_index)?;
11009                match base_write {
11010                    ExternalTriggerBaseWrite::Put { table, cells } => {
11011                        let table_id = self.table_id(&table)?;
11012                        staging.push((table_id, crate::txn::Staged::Put(cells)));
11013                    }
11014                    ExternalTriggerBaseWrite::Delete { table, row_id } => {
11015                        let table_id = self.table_id(&table)?;
11016                        staging.push((table_id, crate::txn::Staged::Delete(row_id)));
11017                    }
11018                }
11019            }
11020        }
11021        dedup_external_states_in_place(external_states);
11022        Ok(())
11023    }
11024
11025    fn expand_table_triggers_once(
11026        &self,
11027        staging: &mut Vec<(u64, crate::txn::Staged)>,
11028        read_epoch: Epoch,
11029        trigger_stacks: Option<&[Vec<String>]>,
11030        config: &TriggerConfig,
11031        control: Option<&crate::ExecutionControl>,
11032    ) -> Result<TriggerExpansion> {
11033        commit_prepare_checkpoint(control, 0)?;
11034        let triggers: Vec<StoredTrigger> = self
11035            .catalog
11036            .read()
11037            .triggers
11038            .iter()
11039            .filter(|entry| {
11040                entry.trigger.enabled
11041                    && matches!(
11042                        entry.trigger.timing,
11043                        TriggerTiming::Before | TriggerTiming::After
11044                    )
11045                    && matches!(entry.trigger.target, TriggerTarget::Table(_))
11046            })
11047            .map(|entry| entry.trigger.clone())
11048            .collect();
11049        if triggers.is_empty() || staging.is_empty() {
11050            return Ok(TriggerExpansion::default());
11051        }
11052
11053        let before_triggers = triggers
11054            .iter()
11055            .filter(|trigger| trigger.timing == TriggerTiming::Before)
11056            .cloned()
11057            .collect::<Vec<_>>();
11058        let after_triggers = triggers
11059            .iter()
11060            .filter(|trigger| trigger.timing == TriggerTiming::After)
11061            .cloned()
11062            .collect::<Vec<_>>();
11063
11064        let mut before_added = Vec::new();
11065        let mut before_stacks = Vec::new();
11066        let mut before_external = Vec::new();
11067        let mut ignored_indices = std::collections::BTreeSet::new();
11068        if !before_triggers.is_empty() {
11069            let before_events =
11070                self.trigger_events_for_staging(staging, read_epoch, trigger_stacks, control)?;
11071            let mut out = TriggerProgramOutput {
11072                added: &mut before_added,
11073                added_stacks: &mut before_stacks,
11074                added_external: &mut before_external,
11075                ignored_indices: &mut ignored_indices,
11076            };
11077            self.execute_triggers_for_events(
11078                &before_triggers,
11079                &before_events,
11080                Some(staging),
11081                &mut out,
11082                config,
11083                read_epoch,
11084                control,
11085            )?;
11086        }
11087
11088        let after_events = if after_triggers.is_empty() {
11089            Vec::new()
11090        } else {
11091            self.trigger_events_for_staging(staging, read_epoch, trigger_stacks, control)?
11092                .into_iter()
11093                .filter(|event| {
11094                    !event
11095                        .op_indices
11096                        .iter()
11097                        .any(|idx| ignored_indices.contains(idx))
11098                })
11099                .collect()
11100        };
11101
11102        let mut after_added = Vec::new();
11103        let mut after_stacks = Vec::new();
11104        let mut after_external = Vec::new();
11105        let mut out = TriggerProgramOutput {
11106            added: &mut after_added,
11107            added_stacks: &mut after_stacks,
11108            added_external: &mut after_external,
11109            ignored_indices: &mut ignored_indices,
11110        };
11111        self.execute_triggers_for_events(
11112            &after_triggers,
11113            &after_events,
11114            None,
11115            &mut out,
11116            config,
11117            read_epoch,
11118            control,
11119        )?;
11120        Ok(TriggerExpansion {
11121            before: before_added,
11122            before_stacks,
11123            before_external,
11124            after: after_added,
11125            after_stacks,
11126            after_external,
11127            ignored_indices,
11128        })
11129    }
11130
11131    #[allow(clippy::too_many_arguments)]
11132    fn execute_triggers_for_events(
11133        &self,
11134        triggers: &[StoredTrigger],
11135        events: &[WriteEvent],
11136        mut staging: Option<&mut Vec<(u64, crate::txn::Staged)>>,
11137        out: &mut TriggerProgramOutput<'_>,
11138        config: &TriggerConfig,
11139        read_epoch: Epoch,
11140        control: Option<&crate::ExecutionControl>,
11141    ) -> Result<()> {
11142        let mut checkpoint_index = 0_usize;
11143        for event in events {
11144            for trigger in triggers {
11145                commit_prepare_checkpoint(control, checkpoint_index)?;
11146                checkpoint_index += 1;
11147                if event
11148                    .op_indices
11149                    .iter()
11150                    .any(|idx| out.ignored_indices.contains(idx))
11151                {
11152                    break;
11153                }
11154                let matches = {
11155                    let cat = self.catalog.read();
11156                    trigger_matches_event(trigger, event, &cat)?
11157                };
11158                if !matches {
11159                    continue;
11160                }
11161                if let Some(when) = &trigger.when {
11162                    if !eval_trigger_expr(when, event)? {
11163                        continue;
11164                    }
11165                }
11166                let trigger_stack = Self::trigger_stack_with(&event.trigger_stack, &trigger.name);
11167                if event.trigger_stack.iter().any(|name| name == &trigger.name) {
11168                    return Err(MongrelError::TriggerValidation(format!(
11169                        "trigger recursion cycle detected; trigger stack: {}",
11170                        Self::format_trigger_stack(&trigger_stack)
11171                    )));
11172                }
11173                let outcome = match staging.as_mut() {
11174                    Some(staging) => self.execute_trigger_program(
11175                        trigger,
11176                        event,
11177                        Some(&mut **staging),
11178                        out,
11179                        &trigger_stack,
11180                        config,
11181                        read_epoch,
11182                        control,
11183                    )?,
11184                    None => self.execute_trigger_program(
11185                        trigger,
11186                        event,
11187                        None,
11188                        out,
11189                        &trigger_stack,
11190                        config,
11191                        read_epoch,
11192                        control,
11193                    )?,
11194                };
11195                if outcome == TriggerProgramOutcome::Ignore {
11196                    out.ignored_indices.extend(event.op_indices.iter().copied());
11197                    break;
11198                }
11199            }
11200        }
11201        Ok(())
11202    }
11203
11204    fn trigger_events_for_staging(
11205        &self,
11206        staging: &[(u64, crate::txn::Staged)],
11207        read_epoch: Epoch,
11208        trigger_stacks: Option<&[Vec<String>]>,
11209        control: Option<&crate::ExecutionControl>,
11210    ) -> Result<Vec<WriteEvent>> {
11211        use crate::txn::Staged;
11212        use std::collections::{HashMap, VecDeque};
11213
11214        let snapshot = self.snapshot_for_epoch(read_epoch);
11215        let cat = self.catalog.read();
11216        let mut table_names = HashMap::new();
11217        let mut table_schemas = HashMap::new();
11218        for entry in cat
11219            .tables
11220            .iter()
11221            .filter(|entry| matches!(entry.state, TableState::Live))
11222        {
11223            table_names.insert(entry.table_id, entry.name.clone());
11224            table_schemas.insert(entry.table_id, entry.schema.clone());
11225        }
11226        drop(cat);
11227
11228        let mut old_rows: HashMap<usize, TriggerRowImage> = HashMap::new();
11229        let mut delete_by_key: HashMap<(u64, Vec<u8>), VecDeque<usize>> = HashMap::new();
11230        let mut put_by_key: HashMap<(u64, Vec<u8>), VecDeque<usize>> = HashMap::new();
11231
11232        for (idx, (table_id, staged)) in staging.iter().enumerate() {
11233            commit_prepare_checkpoint(control, idx)?;
11234            let Some(schema) = table_schemas.get(table_id) else {
11235                continue;
11236            };
11237            let Some(pk) = schema.primary_key() else {
11238                continue;
11239            };
11240            match staged {
11241                Staged::Delete(row_id) => {
11242                    let handle = self.table_by_id(*table_id)?;
11243                    let Some(row) = handle.lock().get(*row_id, snapshot) else {
11244                        continue;
11245                    };
11246                    let Some(pk_value) = row.columns.get(&pk.id) else {
11247                        continue;
11248                    };
11249                    old_rows.insert(idx, TriggerRowImage::from_row(row.clone()));
11250                    delete_by_key
11251                        .entry((*table_id, pk_value.encode_key()))
11252                        .or_default()
11253                        .push_back(idx);
11254                }
11255                Staged::Put(cells) => {
11256                    if let Some((_, value)) = cells.iter().find(|(id, _)| *id == pk.id) {
11257                        put_by_key
11258                            .entry((*table_id, value.encode_key()))
11259                            .or_default()
11260                            .push_back(idx);
11261                    }
11262                }
11263                Staged::Update { row_id, .. } => {
11264                    let handle = self.table_by_id(*table_id)?;
11265                    let row = handle.lock().get(*row_id, snapshot);
11266                    if let Some(row) = row {
11267                        old_rows.insert(idx, TriggerRowImage::from_row(row));
11268                    }
11269                }
11270                Staged::Truncate => {}
11271            }
11272        }
11273
11274        let mut paired_delete = std::collections::HashSet::new();
11275        let mut paired_put = std::collections::HashSet::new();
11276        let mut events = Vec::new();
11277
11278        for (pair_index, (key, deletes)) in delete_by_key.iter_mut().enumerate() {
11279            commit_prepare_checkpoint(control, pair_index)?;
11280            let Some(puts) = put_by_key.get_mut(key) else {
11281                continue;
11282            };
11283            while let (Some(delete_idx), Some(put_idx)) = (deletes.pop_front(), puts.pop_front()) {
11284                paired_delete.insert(delete_idx);
11285                paired_put.insert(put_idx);
11286                let (table_id, _) = &staging[put_idx];
11287                let Some(table_name) = table_names.get(table_id).cloned() else {
11288                    continue;
11289                };
11290                let old = old_rows.get(&delete_idx).cloned();
11291                let new = match &staging[put_idx].1 {
11292                    Staged::Put(cells) => Some(TriggerRowImage::from_cells(cells)),
11293                    _ => None,
11294                };
11295                let changed_columns = changed_columns(old.as_ref(), new.as_ref());
11296                events.push(WriteEvent {
11297                    table: table_name,
11298                    kind: TriggerEvent::Update,
11299                    old,
11300                    new,
11301                    changed_columns,
11302                    op_indices: vec![delete_idx, put_idx],
11303                    put_idx: Some(put_idx),
11304                    trigger_stack: Self::trigger_stack_for_indices(
11305                        trigger_stacks,
11306                        &[delete_idx, put_idx],
11307                    ),
11308                });
11309            }
11310        }
11311
11312        for (idx, (table_id, staged)) in staging.iter().enumerate() {
11313            commit_prepare_checkpoint(control, idx)?;
11314            let Some(table_name) = table_names.get(table_id).cloned() else {
11315                continue;
11316            };
11317            match staged {
11318                Staged::Put(cells) if !paired_put.contains(&idx) => {
11319                    let new = Some(TriggerRowImage::from_cells(cells));
11320                    let changed_columns = cells.iter().map(|(id, _)| *id).collect();
11321                    events.push(WriteEvent {
11322                        table: table_name,
11323                        kind: TriggerEvent::Insert,
11324                        old: None,
11325                        new,
11326                        changed_columns,
11327                        op_indices: vec![idx],
11328                        put_idx: Some(idx),
11329                        trigger_stack: Self::trigger_stack_for_indices(trigger_stacks, &[idx]),
11330                    });
11331                }
11332                Staged::Delete(row_id) if !paired_delete.contains(&idx) => {
11333                    let old = match old_rows.get(&idx).cloned() {
11334                        Some(old) => Some(old),
11335                        None => {
11336                            let handle = self.table_by_id(*table_id)?;
11337                            let row = handle.lock().get(*row_id, snapshot);
11338                            row.map(TriggerRowImage::from_row)
11339                        }
11340                    };
11341                    let Some(old) = old else {
11342                        continue;
11343                    };
11344                    let changed_columns = old.columns.keys().copied().collect();
11345                    events.push(WriteEvent {
11346                        table: table_name,
11347                        kind: TriggerEvent::Delete,
11348                        old: Some(old),
11349                        new: None,
11350                        changed_columns,
11351                        op_indices: vec![idx],
11352                        put_idx: None,
11353                        trigger_stack: Self::trigger_stack_for_indices(trigger_stacks, &[idx]),
11354                    });
11355                }
11356                Staged::Update { new_row: cells, .. } => {
11357                    let old = old_rows.get(&idx).cloned();
11358                    let new = Some(TriggerRowImage::from_cells(cells));
11359                    let changed_columns = changed_columns(old.as_ref(), new.as_ref());
11360                    events.push(WriteEvent {
11361                        table: table_name,
11362                        kind: TriggerEvent::Update,
11363                        old,
11364                        new,
11365                        changed_columns,
11366                        op_indices: vec![idx],
11367                        put_idx: Some(idx),
11368                        trigger_stack: Self::trigger_stack_for_indices(trigger_stacks, &[idx]),
11369                    });
11370                }
11371                Staged::Truncate => {}
11372                _ => {}
11373            }
11374        }
11375
11376        Ok(events)
11377    }
11378
11379    #[allow(clippy::too_many_arguments)]
11380    fn execute_trigger_program(
11381        &self,
11382        trigger: &StoredTrigger,
11383        event: &WriteEvent,
11384        staging: Option<&mut Vec<(u64, crate::txn::Staged)>>,
11385        out: &mut TriggerProgramOutput<'_>,
11386        trigger_stack: &[String],
11387        config: &TriggerConfig,
11388        read_epoch: Epoch,
11389        control: Option<&crate::ExecutionControl>,
11390    ) -> Result<TriggerProgramOutcome> {
11391        let mut event = event.clone();
11392        let mut select_results: HashMap<String, Vec<TriggerRowImage>> = HashMap::new();
11393        self.execute_trigger_steps(
11394            trigger,
11395            &trigger.program.steps,
11396            &mut event,
11397            staging,
11398            out,
11399            trigger_stack,
11400            config,
11401            &mut select_results,
11402            0,
11403            None,
11404            read_epoch,
11405            control,
11406        )
11407    }
11408
11409    #[allow(clippy::too_many_arguments)]
11410    fn execute_trigger_steps(
11411        &self,
11412        trigger: &StoredTrigger,
11413        steps: &[TriggerStep],
11414        event: &mut WriteEvent,
11415        mut staging: Option<&mut Vec<(u64, crate::txn::Staged)>>,
11416        out: &mut TriggerProgramOutput<'_>,
11417        trigger_stack: &[String],
11418        config: &TriggerConfig,
11419        select_results: &mut HashMap<String, Vec<TriggerRowImage>>,
11420        depth: u32,
11421        selected: Option<&TriggerRowImage>,
11422        read_epoch: Epoch,
11423        control: Option<&crate::ExecutionControl>,
11424    ) -> Result<TriggerProgramOutcome> {
11425        let _ = depth;
11426        for (step_index, step) in steps.iter().enumerate() {
11427            commit_prepare_checkpoint(control, step_index)?;
11428            match step {
11429                TriggerStep::SetNew { cells } => {
11430                    if trigger.timing != TriggerTiming::Before {
11431                        return Err(MongrelError::InvalidArgument(
11432                            "SetNew trigger step is only valid in BEFORE triggers".into(),
11433                        ));
11434                    }
11435                    let put_idx = event.put_idx.ok_or_else(|| {
11436                        MongrelError::InvalidArgument(
11437                            "SetNew trigger step requires INSERT or UPDATE NEW row".into(),
11438                        )
11439                    })?;
11440                    let staging = staging.as_deref_mut().ok_or_else(|| {
11441                        MongrelError::InvalidArgument(
11442                            "SetNew trigger step requires mutable trigger staging".into(),
11443                        )
11444                    })?;
11445                    let mut update_changed_columns = None;
11446                    let row_cells = match staging.get_mut(put_idx).map(|(_, op)| op) {
11447                        Some(crate::txn::Staged::Put(cells)) => cells,
11448                        Some(crate::txn::Staged::Update {
11449                            new_row,
11450                            changed_columns,
11451                            ..
11452                        }) => {
11453                            update_changed_columns = Some(changed_columns);
11454                            new_row
11455                        }
11456                        _ => {
11457                            return Err(MongrelError::InvalidArgument(
11458                                "SetNew trigger step target row is not mutable".into(),
11459                            ))
11460                        }
11461                    };
11462                    for (column_id, value) in eval_trigger_cells(cells, event, selected)? {
11463                        row_cells.retain(|(id, _)| *id != column_id);
11464                        row_cells.push((column_id, value.clone()));
11465                        if let Some(changed_columns) = &mut update_changed_columns {
11466                            changed_columns.push(column_id);
11467                        }
11468                        if let Some(new) = &mut event.new {
11469                            new.columns.insert(column_id, value);
11470                        }
11471                    }
11472                    row_cells.sort_by_key(|(id, _)| *id);
11473                    if let Some(changed_columns) = update_changed_columns {
11474                        changed_columns.sort_unstable();
11475                        changed_columns.dedup();
11476                    }
11477                }
11478                TriggerStep::Insert { table, cells } => {
11479                    let cells = eval_trigger_cells(cells, event, selected)?;
11480                    if let Ok(table_id) = self.table_id(table) {
11481                        out.added.push((table_id, crate::txn::Staged::Put(cells)));
11482                        out.added_stacks.push(trigger_stack.to_vec());
11483                    } else if self.external_table(table).is_some() {
11484                        out.added_external.push(ExternalTriggerWrite::Insert {
11485                            table: table.clone(),
11486                            cells,
11487                        });
11488                    } else {
11489                        return Err(MongrelError::NotFound(format!(
11490                            "trigger {:?} insert target {table:?} not found",
11491                            trigger.name
11492                        )));
11493                    }
11494                }
11495                TriggerStep::UpdateByPk { table, pk, cells } => {
11496                    let pk = eval_trigger_value(pk, event, selected)?;
11497                    let cells = eval_trigger_cells(cells, event, selected)?;
11498                    if self.external_table(table).is_some() {
11499                        out.added_external.push(ExternalTriggerWrite::UpdateByPk {
11500                            table: table.clone(),
11501                            pk,
11502                            cells,
11503                        });
11504                    } else {
11505                        let row_id = self
11506                            .table(table)?
11507                            .lock()
11508                            .lookup_pk(&pk.encode_key())
11509                            .ok_or_else(|| {
11510                                MongrelError::NotFound(format!(
11511                                    "trigger {:?} update target not found",
11512                                    trigger.name
11513                                ))
11514                            })?;
11515                        let handle = self.table(table)?;
11516                        let snapshot = self.snapshot_for_epoch(self.epoch.visible());
11517                        let old = handle.lock().get(row_id, snapshot).ok_or_else(|| {
11518                            MongrelError::NotFound(format!(
11519                                "trigger {:?} update target not visible",
11520                                trigger.name
11521                            ))
11522                        })?;
11523                        let mut changed_columns = cells
11524                            .iter()
11525                            .map(|(column_id, _)| *column_id)
11526                            .collect::<Vec<_>>();
11527                        changed_columns.sort_unstable();
11528                        changed_columns.dedup();
11529                        let mut merged = old.columns;
11530                        for (column_id, value) in cells {
11531                            merged.insert(column_id, value);
11532                        }
11533                        out.added.push((
11534                            self.table_id(table)?,
11535                            crate::txn::Staged::Update {
11536                                row_id,
11537                                new_row: merged.into_iter().collect(),
11538                                changed_columns,
11539                            },
11540                        ));
11541                        out.added_stacks.push(trigger_stack.to_vec());
11542                    }
11543                }
11544                TriggerStep::DeleteByPk { table, pk } => {
11545                    let pk = eval_trigger_value(pk, event, selected)?;
11546                    if self.external_table(table).is_some() {
11547                        out.added_external.push(ExternalTriggerWrite::DeleteByPk {
11548                            table: table.clone(),
11549                            pk,
11550                        });
11551                    } else {
11552                        let row_id = self
11553                            .table(table)?
11554                            .lock()
11555                            .lookup_pk(&pk.encode_key())
11556                            .ok_or_else(|| {
11557                                MongrelError::NotFound(format!(
11558                                    "trigger {:?} delete target not found",
11559                                    trigger.name
11560                                ))
11561                            })?;
11562                        out.added
11563                            .push((self.table_id(table)?, crate::txn::Staged::Delete(row_id)));
11564                        out.added_stacks.push(trigger_stack.to_vec());
11565                    }
11566                }
11567                TriggerStep::Select {
11568                    id,
11569                    table,
11570                    conditions,
11571                } => {
11572                    let schema = self.table(table)?.lock().schema().clone();
11573                    let snapshot = self.snapshot_for_epoch(read_epoch);
11574                    let handle = self.table(table)?;
11575                    let rows = match control {
11576                        Some(control) => {
11577                            handle.lock().visible_rows_controlled(snapshot, control)?
11578                        }
11579                        None => handle.lock().visible_rows(snapshot)?,
11580                    };
11581                    let mut matched = Vec::new();
11582                    for (row_index, row) in rows.into_iter().enumerate() {
11583                        commit_prepare_checkpoint(control, row_index)?;
11584                        let image = TriggerRowImage::from_row(row);
11585                        let passes = conditions
11586                            .iter()
11587                            .map(|cond| eval_trigger_condition(cond, event, &image, &schema))
11588                            .collect::<Result<Vec<_>>>()?
11589                            .into_iter()
11590                            .all(|b| b);
11591                        if passes {
11592                            matched.push(image);
11593                        }
11594                    }
11595                    if let Some(pk) = schema.primary_key() {
11596                        matched.sort_by(|a, b| {
11597                            let av = a.columns.get(&pk.id).unwrap_or(&Value::Null);
11598                            let bv = b.columns.get(&pk.id).unwrap_or(&Value::Null);
11599                            value_order(av, bv).unwrap_or(std::cmp::Ordering::Equal)
11600                        });
11601                    }
11602                    select_results.insert(id.clone(), matched);
11603                }
11604                TriggerStep::Foreach { id, steps } => {
11605                    let rows = select_results.get(id).ok_or_else(|| {
11606                        MongrelError::InvalidArgument(format!(
11607                            "trigger {:?} foreach references unknown select id {id:?}",
11608                            trigger.name
11609                        ))
11610                    })?;
11611                    if rows.len() > config.max_loop_iterations as usize {
11612                        return Err(MongrelError::InvalidArgument(format!(
11613                            "trigger {:?} foreach exceeded max_loop_iterations ({})",
11614                            trigger.name, config.max_loop_iterations
11615                        )));
11616                    }
11617                    for (row_index, row) in rows.clone().into_iter().enumerate() {
11618                        commit_prepare_checkpoint(control, row_index)?;
11619                        let result = self.execute_trigger_steps(
11620                            trigger,
11621                            steps,
11622                            event,
11623                            staging.as_deref_mut(),
11624                            out,
11625                            trigger_stack,
11626                            config,
11627                            select_results,
11628                            depth + 1,
11629                            Some(&row),
11630                            read_epoch,
11631                            control,
11632                        )?;
11633                        if result == TriggerProgramOutcome::Ignore {
11634                            return Ok(TriggerProgramOutcome::Ignore);
11635                        }
11636                    }
11637                }
11638                TriggerStep::DeleteWhere { table, conditions } => {
11639                    let schema = self.table(table)?.lock().schema().clone();
11640                    let snapshot = self.snapshot_for_epoch(read_epoch);
11641                    let handle = self.table(table)?;
11642                    let rows = match control {
11643                        Some(control) => {
11644                            handle.lock().visible_rows_controlled(snapshot, control)?
11645                        }
11646                        None => handle.lock().visible_rows(snapshot)?,
11647                    };
11648                    let table_id = self.table_id(table)?;
11649                    let mut to_delete = Vec::new();
11650                    for (row_index, row) in rows.into_iter().enumerate() {
11651                        commit_prepare_checkpoint(control, row_index)?;
11652                        let image = TriggerRowImage::from_row(row.clone());
11653                        let passes = conditions
11654                            .iter()
11655                            .map(|cond| eval_trigger_condition(cond, event, &image, &schema))
11656                            .collect::<Result<Vec<_>>>()?
11657                            .into_iter()
11658                            .all(|b| b);
11659                        if passes {
11660                            to_delete.push((table_id, row.row_id));
11661                        }
11662                    }
11663                    for (row_index, (table_id, row_id)) in to_delete.into_iter().enumerate() {
11664                        commit_prepare_checkpoint(control, row_index)?;
11665                        out.added
11666                            .push((table_id, crate::txn::Staged::Delete(row_id)));
11667                        out.added_stacks.push(trigger_stack.to_vec());
11668                    }
11669                }
11670                TriggerStep::UpdateWhere {
11671                    table,
11672                    conditions,
11673                    cells,
11674                } => {
11675                    let schema = self.table(table)?.lock().schema().clone();
11676                    let snapshot = self.snapshot_for_epoch(read_epoch);
11677                    let handle = self.table(table)?;
11678                    let rows = match control {
11679                        Some(control) => {
11680                            handle.lock().visible_rows_controlled(snapshot, control)?
11681                        }
11682                        None => handle.lock().visible_rows(snapshot)?,
11683                    };
11684                    let table_id = self.table_id(table)?;
11685                    let mut changed_columns =
11686                        cells.iter().map(|cell| cell.column_id).collect::<Vec<_>>();
11687                    changed_columns.sort_unstable();
11688                    changed_columns.dedup();
11689                    let mut to_update = Vec::new();
11690                    for (row_index, row) in rows.into_iter().enumerate() {
11691                        commit_prepare_checkpoint(control, row_index)?;
11692                        let image = TriggerRowImage::from_row(row.clone());
11693                        let passes = conditions
11694                            .iter()
11695                            .map(|cond| eval_trigger_condition(cond, event, &image, &schema))
11696                            .collect::<Result<Vec<_>>>()?
11697                            .into_iter()
11698                            .all(|b| b);
11699                        if passes {
11700                            let new_cells = cells
11701                                .iter()
11702                                .map(|cell| {
11703                                    Ok((
11704                                        cell.column_id,
11705                                        eval_trigger_value(&cell.value, event, Some(&image))?,
11706                                    ))
11707                                })
11708                                .collect::<Result<Vec<_>>>()?;
11709                            let mut merged = row.columns.clone();
11710                            for (column_id, value) in new_cells {
11711                                merged.insert(column_id, value);
11712                            }
11713                            to_update.push((table_id, row.row_id, merged));
11714                        }
11715                    }
11716                    for (row_index, (table_id, row_id, merged)) in to_update.into_iter().enumerate()
11717                    {
11718                        commit_prepare_checkpoint(control, row_index)?;
11719                        out.added.push((
11720                            table_id,
11721                            crate::txn::Staged::Update {
11722                                row_id,
11723                                new_row: merged.into_iter().collect(),
11724                                changed_columns: changed_columns.clone(),
11725                            },
11726                        ));
11727                        out.added_stacks.push(trigger_stack.to_vec());
11728                    }
11729                }
11730                TriggerStep::Raise { action, message } => match action {
11731                    TriggerRaiseAction::Ignore => return Ok(TriggerProgramOutcome::Ignore),
11732                    TriggerRaiseAction::Abort
11733                    | TriggerRaiseAction::Fail
11734                    | TriggerRaiseAction::Rollback => {
11735                        let message = eval_trigger_value(message, event, selected)?;
11736                        return Err(MongrelError::TriggerValidation(format!(
11737                            "trigger {:?} raised: {}; trigger stack: {}",
11738                            trigger.name,
11739                            trigger_message(message),
11740                            Self::format_trigger_stack(trigger_stack)
11741                        )));
11742                    }
11743                },
11744            }
11745        }
11746        Ok(TriggerProgramOutcome::Continue)
11747    }
11748
11749    fn trigger_stack_for_indices(stacks: Option<&[Vec<String>]>, indices: &[usize]) -> Vec<String> {
11750        let Some(stacks) = stacks else {
11751            return Vec::new();
11752        };
11753        let mut out = Vec::new();
11754        for idx in indices {
11755            let Some(stack) = stacks.get(*idx) else {
11756                continue;
11757            };
11758            for name in stack {
11759                if !out.iter().any(|existing| existing == name) {
11760                    out.push(name.clone());
11761                }
11762            }
11763        }
11764        out
11765    }
11766
11767    fn trigger_stack_with(stack: &[String], trigger_name: &str) -> Vec<String> {
11768        let mut out = stack.to_vec();
11769        out.push(trigger_name.to_string());
11770        out
11771    }
11772
11773    fn format_trigger_stack(stack: &[String]) -> String {
11774        if stack.is_empty() {
11775            "<root>".into()
11776        } else {
11777            stack.join(" -> ")
11778        }
11779    }
11780
11781    /// Authoritatively validate every declared constraint on the staged write
11782    /// set under the transaction's read snapshot, AND expand ON DELETE CASCADE /
11783    /// SET NULL actions into explicit child ops. Called from
11784    /// [`Self::commit_transaction`] outside the WAL mutex. Returns the first
11785    /// violation as an `Err`, aborting the commit atomically. This is the
11786    /// server-side authority point: concurrent remote writers that each pass
11787    /// their own client-side checks still cannot both commit a violating batch.
11788    ///
11789    /// Scope: CHECK (full, three-valued), UNIQUE beyond the PK (existence scan +
11790    /// intra-transaction dedup; concurrent-txn races are additionally caught by
11791    /// `WriteKey::Unique`), and FK insert-side parent existence + ON DELETE
11792    /// {RESTRICT, CASCADE, SET NULL}. CASCADE appends child deletes (transitive
11793    /// fixpoint); SET NULL appends child updates (FK columns nulled). Truncate is
11794    /// RESTRICT-only (cascade-truncate is unsupported).
11795    /// S1B-003: acquire Exclusive key claims for every primary key and declared
11796    /// UNIQUE key a transaction's staged puts/updates insert, in ascending key
11797    /// order (ordered acquisition cannot cycle on its own). A concurrent
11798    /// transaction claiming the same key blocks until this one ends, turning
11799    /// the optimistic write-write conflict into a serialization point. Claims
11800    /// release with the transaction's [`TxnLockGuard`].
11801    fn acquire_unique_key_claims(
11802        &self,
11803        txn_id: u64,
11804        staging: &[(u64, crate::txn::Staged)],
11805        control: Option<&crate::ExecutionControl>,
11806    ) -> Result<()> {
11807        let catalog = self.catalog.read();
11808        let has_uniques = staging.iter().any(|(table_id, staged)| {
11809            matches!(
11810                staged,
11811                crate::txn::Staged::Put(_) | crate::txn::Staged::Update { .. }
11812            ) && catalog.tables.iter().any(|entry| {
11813                entry.table_id == *table_id
11814                    && (entry.schema.primary_key().is_some()
11815                        || !entry.schema.constraints.uniques.is_empty())
11816            })
11817        });
11818        if !has_uniques {
11819            return Ok(());
11820        }
11821        let mut claims: Vec<(u64, Vec<u8>)> = Vec::new();
11822        for (staged_index, (table_id, staged)) in staging.iter().enumerate() {
11823            commit_prepare_checkpoint(control, staged_index)?;
11824            let cells = match staged {
11825                crate::txn::Staged::Put(cells) => cells,
11826                crate::txn::Staged::Update { new_row, .. } => new_row,
11827                _ => continue,
11828            };
11829            let Some(entry) = catalog
11830                .tables
11831                .iter()
11832                .find(|entry| entry.table_id == *table_id)
11833            else {
11834                continue;
11835            };
11836            for column in &entry.schema.columns {
11837                if !column
11838                    .flags
11839                    .contains(crate::schema::ColumnFlags::PRIMARY_KEY)
11840                {
11841                    continue;
11842                }
11843                if let Some((_, value)) = cells.iter().find(|(id, _)| *id == column.id) {
11844                    let mut key = b"pk:".to_vec();
11845                    key.extend_from_slice(&value.encode_key());
11846                    claims.push((*table_id, key));
11847                }
11848            }
11849            // Declared non-PK unique constraints claim their own namespace
11850            // (folding the constraint id into the key, per LockKey::Key's
11851            // multi-key-space rule). NULL components skip the constraint —
11852            // and the claim — per SQL semantics.
11853            let cells_map: HashMap<u16, Value> = cells.iter().cloned().collect();
11854            for uc in &entry.schema.constraints.uniques {
11855                if let Some(composite) =
11856                    crate::constraint::encode_composite_key(&uc.columns, &cells_map)
11857                {
11858                    let mut key = format!("uq{}:", uc.id).into_bytes();
11859                    key.extend_from_slice(&composite);
11860                    claims.push((*table_id, key));
11861                }
11862            }
11863        }
11864        claims.sort();
11865        claims.dedup();
11866        for (table_id, key) in claims {
11867            self.acquire_txn_lock(
11868                txn_id,
11869                crate::locks::LockKey::key(table_id, key),
11870                crate::locks::LockMode::Exclusive,
11871                control,
11872            )?;
11873        }
11874        Ok(())
11875    }
11876
11877    /// S1B-003: one FK parent-protection acquisition. `Err` propagates the
11878    /// deadlock/deadline/cancellation outcome; the test seam fires after each
11879    /// successful acquisition.
11880    fn acquire_fk_lock(
11881        &self,
11882        txn_id: u64,
11883        table_id: u64,
11884        key: &[u8],
11885        mode: crate::locks::LockMode,
11886        control: Option<&crate::ExecutionControl>,
11887    ) -> Result<()> {
11888        let mut namespaced = b"fk:".to_vec();
11889        namespaced.extend_from_slice(key);
11890        self.acquire_txn_lock(
11891            txn_id,
11892            crate::locks::LockKey::key(table_id, namespaced),
11893            mode,
11894            control,
11895        )?;
11896        // The hook is cloned out before firing: holding the slot's mutex while
11897        // a parked hook waits would block every other commit's hook call.
11898        let hook = self.fk_lock_hook.lock().clone();
11899        if let Some(hook) = hook {
11900            hook();
11901        }
11902        Ok(())
11903    }
11904
11905    fn validate_constraints(
11906        &self,
11907        txn_id: u64,
11908        staging: &mut Vec<(u64, crate::txn::Staged)>,
11909        read_epoch: Epoch,
11910        principal: Option<&crate::auth::Principal>,
11911        control: Option<&crate::ExecutionControl>,
11912    ) -> Result<()> {
11913        use crate::constraint::{encode_composite_key, validate_checks, FkAction};
11914        use crate::memtable::Row;
11915        use crate::txn::Staged;
11916        use std::collections::HashSet;
11917
11918        commit_prepare_checkpoint(control, 0)?;
11919        // P0.5: constraint checks must use an HLC-pinned snapshot so parent /
11920        // unique rows stamped with commit_ts remain visible.
11921        let snapshot = self.snapshot_for_epoch(read_epoch);
11922        // Check the no-constraint fast path while borrowing the catalog. The
11923        // previous path cloned the complete catalog and every live schema before
11924        // discovering there was no constraint work to do.
11925        let (live, table_ids_by_name) = {
11926            let catalog = self.catalog.read();
11927            let any_constraints = catalog.tables.iter().any(|entry| {
11928                matches!(entry.state, TableState::Live | TableState::Building { .. })
11929                    && !entry.schema.constraints.is_empty()
11930            });
11931            if !any_constraints {
11932                drop(catalog);
11933                self.materialize_generated_embeddings(staging, control)?;
11934                return Ok(());
11935            }
11936            let live: Vec<(u64, String, crate::schema::Schema)> = catalog
11937                .tables
11938                .iter()
11939                .filter(|entry| {
11940                    matches!(entry.state, TableState::Live | TableState::Building { .. })
11941                })
11942                .map(|entry| (entry.table_id, entry.name.clone(), entry.schema.clone()))
11943                .collect();
11944            let table_ids_by_name: HashMap<String, u64> = catalog
11945                .tables
11946                .iter()
11947                .map(|entry| (entry.name.clone(), entry.table_id))
11948                .collect();
11949            (live, table_ids_by_name)
11950        };
11951
11952        // Lazily-loaded visible rows per table, shared across checks. Cache hits
11953        // clone only the `Arc`, not every row and its column values.
11954        let mut rows_cache: HashMap<u64, Arc<Vec<Row>>> = HashMap::new();
11955        let mut load_rows = |table_id: u64| -> Result<Arc<Vec<Row>>> {
11956            if let Some(rows) = rows_cache.get(&table_id) {
11957                return Ok(Arc::clone(rows));
11958            }
11959            let handle = self.table_by_id(table_id)?;
11960            let rows = Arc::new(match control {
11961                Some(control) => handle.lock().visible_rows_controlled(snapshot, control)?,
11962                None => handle.lock().visible_rows(snapshot)?,
11963            });
11964            rows_cache.insert(table_id, Arc::clone(&rows));
11965            Ok(rows)
11966        };
11967
11968        // ── Phase A1: expand ON UPDATE CASCADE / SET NULL while updates still
11969        // carry an explicit old RowId + full new image. This makes action choice
11970        // reliable even when the referenced key itself changes; a delete+put
11971        // heuristic cannot distinguish that from unrelated operations.
11972        let mut processed_updates = HashSet::new();
11973        type PendingUpdate = (usize, u64, crate::rowid::RowId, Vec<(u16, Value)>);
11974        let mut update_pass = 0_usize;
11975        loop {
11976            commit_prepare_checkpoint(control, update_pass)?;
11977            update_pass += 1;
11978            let updates: Vec<PendingUpdate> = staging
11979                .iter()
11980                .enumerate()
11981                .filter_map(|(index, (table_id, op))| match op {
11982                    Staged::Update {
11983                        row_id,
11984                        new_row: cells,
11985                        ..
11986                    } if !processed_updates.contains(&index) => {
11987                        Some((index, *table_id, *row_id, cells.clone()))
11988                    }
11989                    _ => None,
11990                })
11991                .collect();
11992            if updates.is_empty() {
11993                break;
11994            }
11995            let mut new_ops = Vec::new();
11996            for (update_index, (index, table_id, row_id, new_cells)) in
11997                updates.into_iter().enumerate()
11998            {
11999                commit_prepare_checkpoint(control, update_index)?;
12000                processed_updates.insert(index);
12001                let Some(tname) = live
12002                    .iter()
12003                    .find(|(id, _, _)| *id == table_id)
12004                    .map(|(_, name, _)| name.as_str())
12005                else {
12006                    continue;
12007                };
12008                let Some(old_row) = self.table_by_id(table_id)?.lock().get(row_id, snapshot) else {
12009                    continue;
12010                };
12011                let new_map: HashMap<u16, Value> = new_cells.iter().cloned().collect();
12012                for (child_id, _child_name, child_schema) in &live {
12013                    for fk in &child_schema.constraints.foreign_keys {
12014                        if fk.ref_table != tname {
12015                            continue;
12016                        }
12017                        let Some(old_key) = encode_composite_key(&fk.ref_columns, &old_row.columns)
12018                        else {
12019                            continue;
12020                        };
12021                        if encode_composite_key(&fk.ref_columns, &new_map).as_deref()
12022                            == Some(old_key.as_slice())
12023                        {
12024                            continue;
12025                        }
12026                        if fk.on_update == FkAction::Restrict {
12027                            continue;
12028                        }
12029                        // S1B-003: the referenced key is being changed, so this
12030                        // update removes it for any action — hold an Exclusive
12031                        // parent-protection lock against concurrent child
12032                        // inserts referencing the old key.
12033                        self.acquire_fk_lock(
12034                            txn_id,
12035                            table_id,
12036                            &old_key,
12037                            crate::locks::LockMode::Exclusive,
12038                            control,
12039                        )?;
12040                        let child_rows = load_rows(*child_id)?;
12041                        for (child_index, child) in child_rows.iter().enumerate() {
12042                            commit_prepare_checkpoint(control, child_index)?;
12043                            if encode_composite_key(&fk.columns, &child.columns).as_deref()
12044                                != Some(old_key.as_slice())
12045                            {
12046                                continue;
12047                            }
12048                            if staging.iter().any(|(id, op)| {
12049                                *id == *child_id
12050                                    && matches!(op, Staged::Delete(id) if *id == child.row_id)
12051                            }) {
12052                                continue;
12053                            }
12054                            let mut cells: Vec<(u16, Value)> = child
12055                                .columns
12056                                .iter()
12057                                .map(|(column_id, value)| (*column_id, value.clone()))
12058                                .collect();
12059                            for (child_column, parent_column) in
12060                                fk.columns.iter().zip(&fk.ref_columns)
12061                            {
12062                                cells.retain(|(column_id, _)| column_id != child_column);
12063                                let value = match fk.on_update {
12064                                    FkAction::Cascade => {
12065                                        new_map.get(parent_column).cloned().unwrap_or(Value::Null)
12066                                    }
12067                                    FkAction::SetNull => Value::Null,
12068                                    FkAction::Restrict => {
12069                                        return Err(MongrelError::Other(
12070                                            "restricted foreign-key update reached cascade preparation"
12071                                                .into(),
12072                                        ));
12073                                    }
12074                                };
12075                                cells.push((*child_column, value));
12076                            }
12077                            cells.sort_by_key(|(column_id, _)| *column_id);
12078                            if let Some(existing_index) = staging.iter().position(|(id, op)| {
12079                                *id == *child_id
12080                                    && matches!(op, Staged::Update { row_id, .. } if *row_id == child.row_id)
12081                            }) {
12082                                if let Staged::Update {
12083                                    new_row: existing,
12084                                    changed_columns,
12085                                    ..
12086                                } = &mut staging[existing_index].1 {
12087                                    changed_columns.extend(fk.columns.iter().copied());
12088                                    changed_columns.sort_unstable();
12089                                    changed_columns.dedup();
12090                                    if *existing != cells {
12091                                        *existing = cells;
12092                                        processed_updates.remove(&existing_index);
12093                                    }
12094                                }
12095                            } else {
12096                                new_ops.push((
12097                                    *child_id,
12098                                    Staged::Update {
12099                                        row_id: child.row_id,
12100                                        new_row: cells,
12101                                        changed_columns: fk.columns.clone(),
12102                                    },
12103                                ));
12104                            }
12105                        }
12106                    }
12107                }
12108            }
12109            staging.extend(new_ops);
12110        }
12111
12112        // ── Phase A2: expand ON DELETE CASCADE / SET NULL into explicit child
12113        // ops (transitive fixpoint). RESTRICT is not expanded here — it is
12114        // enforced as a violation in Phase B. `cascaded` records every delete
12115        // we have already expanded so a self-referential CASCADE FK cannot loop.
12116        let mut cascaded: HashSet<(u64, u64)> = HashSet::new();
12117        let mut cascade_pass = 0_usize;
12118        loop {
12119            commit_prepare_checkpoint(control, cascade_pass)?;
12120            cascade_pass += 1;
12121            let mut new_ops: Vec<(u64, Staged)> = Vec::new();
12122            let deletes: Vec<(u64, crate::rowid::RowId)> = staging
12123                .iter()
12124                .filter_map(|(t, op)| match op {
12125                    Staged::Delete(rid) => Some((*t, *rid)),
12126                    _ => None,
12127                })
12128                .collect();
12129            for (delete_index, (table_id, rid)) in deletes.into_iter().enumerate() {
12130                commit_prepare_checkpoint(control, delete_index)?;
12131                if !cascaded.insert((table_id, rid.0)) {
12132                    continue;
12133                }
12134                let Some(tname) = live
12135                    .iter()
12136                    .find(|(t, _, _)| *t == table_id)
12137                    .map(|(_, n, _)| n.as_str())
12138                else {
12139                    continue;
12140                };
12141                let parent_handle = self.table_by_id(table_id)?;
12142                let Some(parent_row) = parent_handle.lock().get(rid, snapshot) else {
12143                    continue;
12144                };
12145                for (child_id, _child_name, child_schema) in &live {
12146                    for fk in &child_schema.constraints.foreign_keys {
12147                        if fk.ref_table != tname {
12148                            continue;
12149                        }
12150                        let Some(parent_key) =
12151                            encode_composite_key(&fk.ref_columns, &parent_row.columns)
12152                        else {
12153                            continue;
12154                        };
12155                        // Suppress ON DELETE cascade/set-null when this "delete"
12156                        // is actually half of an UPDATE encoded as Delete(old)+
12157                        // Put(new): if a staged Put in the SAME table still
12158                        // provides the referenced parent key, the parent still
12159                        // exists (its non-key columns changed) and the children
12160                        // must be left alone. A genuine delete, or an update
12161                        // that CHANGES the referenced key, has no preserving Put
12162                        // → cascade fires as before.
12163                        let key_preserved = staging.iter().any(|(t, op)| {
12164                            if *t != table_id {
12165                                return false;
12166                            }
12167                            let Staged::Put(cells) = op else {
12168                                return false;
12169                            };
12170                            let map: HashMap<u16, crate::memtable::Value> =
12171                                cells.iter().cloned().collect();
12172                            encode_composite_key(&fk.ref_columns, &map).as_deref()
12173                                == Some(parent_key.as_slice())
12174                        });
12175                        if key_preserved {
12176                            continue;
12177                        }
12178                        // S1B-003: the referenced parent key is genuinely being
12179                        // removed (delete, or the delete half of a key-changing
12180                        // update), for every FK action — hold an Exclusive
12181                        // parent-protection lock against concurrent child
12182                        // inserts referencing it. RESTRICT fks are enforced in
12183                        // Phase B; the claim covers them too.
12184                        self.acquire_fk_lock(
12185                            txn_id,
12186                            table_id,
12187                            &parent_key,
12188                            crate::locks::LockMode::Exclusive,
12189                            control,
12190                        )?;
12191                        match fk.on_delete {
12192                            FkAction::Restrict => continue,
12193                            FkAction::Cascade => {
12194                                let child_rows = load_rows(*child_id)?;
12195                                for (child_index, cr) in child_rows.iter().enumerate() {
12196                                    commit_prepare_checkpoint(control, child_index)?;
12197                                    if !cascaded.contains(&(*child_id, cr.row_id.0))
12198                                        && encode_composite_key(&fk.columns, &cr.columns).as_deref()
12199                                            == Some(parent_key.as_slice())
12200                                    {
12201                                        new_ops.push((*child_id, Staged::Delete(cr.row_id)));
12202                                    }
12203                                }
12204                            }
12205                            FkAction::SetNull => {
12206                                let child_rows = load_rows(*child_id)?;
12207                                for (child_index, cr) in child_rows.iter().enumerate() {
12208                                    commit_prepare_checkpoint(control, child_index)?;
12209                                    if !cascaded.contains(&(*child_id, cr.row_id.0))
12210                                        && encode_composite_key(&fk.columns, &cr.columns).as_deref()
12211                                            == Some(parent_key.as_slice())
12212                                    {
12213                                        // Re-emit the child row with the FK
12214                                        // columns set to NULL (delete + put).
12215                                        let mut cells: Vec<(u16, crate::memtable::Value)> = cr
12216                                            .columns
12217                                            .iter()
12218                                            .map(|(k, v)| (*k, v.clone()))
12219                                            .collect();
12220                                        for cid in &fk.columns {
12221                                            cells.retain(|(k, _)| k != cid);
12222                                            cells.push((*cid, crate::memtable::Value::Null));
12223                                        }
12224                                        new_ops.push((
12225                                            *child_id,
12226                                            Staged::Update {
12227                                                row_id: cr.row_id,
12228                                                new_row: cells,
12229                                                changed_columns: fk.columns.clone(),
12230                                            },
12231                                        ));
12232                                    }
12233                                }
12234                            }
12235                        }
12236                    }
12237                }
12238            }
12239            if new_ops.is_empty() {
12240                break;
12241            }
12242            staging.extend(new_ops);
12243        }
12244
12245        // Constraint actions can add writes. Re-authorize the final write set
12246        // before generated-value providers receive any row content, then
12247        // materialize vectors before Phase B validates the committed cells.
12248        self.validate_write_permissions(staging, principal, control)?;
12249        self.validate_security_writes(staging, read_epoch, principal, control)?;
12250        self.materialize_generated_embeddings(staging, control)?;
12251
12252        // Rows staged for deletion in THIS transaction (now including cascaded
12253        // deletes). Used to exclude the old version of an updated row from
12254        // unique-existence scans.
12255        let staged_deletes: HashSet<(u64, u64)> = staging
12256            .iter()
12257            .filter_map(|(t, op)| match op {
12258                Staged::Delete(rid) | Staged::Update { row_id: rid, .. } => Some((*t, rid.0)),
12259                _ => None,
12260            })
12261            .collect();
12262
12263        // Intra-transaction unique-key dedup: (table_id, uc_id, key).
12264        let mut seen_unique: HashSet<(u64, u16, Vec<u8>)> = HashSet::new();
12265
12266        // ── Phase B: validate the fully-expanded staging set.
12267        for (operation_index, (table_id, op)) in staging.iter().enumerate() {
12268            commit_prepare_checkpoint(control, operation_index)?;
12269            let Some((_, tname, schema)) = live.iter().find(|(t, _, _)| t == table_id) else {
12270                continue;
12271            };
12272            let cells_map: HashMap<u16, crate::memtable::Value>;
12273            match op {
12274                Staged::Put(cells) | Staged::Update { new_row: cells, .. } => {
12275                    cells_map = cells.iter().cloned().collect();
12276
12277                    // CHECK constraints.
12278                    if !schema.constraints.checks.is_empty() {
12279                        validate_checks(&schema.constraints.checks, &cells_map)?;
12280                    }
12281
12282                    // UNIQUE (non-PK) constraints.
12283                    for uc in &schema.constraints.uniques {
12284                        let Some(key) = encode_composite_key(&uc.columns, &cells_map) else {
12285                            continue; // NULL in a constrained column → skip (SQL).
12286                        };
12287                        let marker = (*table_id, uc.id, key.clone());
12288                        if !seen_unique.insert(marker) {
12289                            return Err(MongrelError::Conflict(format!(
12290                                "UNIQUE constraint '{}' on table '{tname}' violated within batch",
12291                                uc.name
12292                            )));
12293                        }
12294                        let rows = load_rows(*table_id)?;
12295                        for (row_index, r) in rows.iter().enumerate() {
12296                            commit_prepare_checkpoint(control, row_index)?;
12297                            // Skip rows this same transaction is deleting (the
12298                            // old version of an updated/cascade-deleted row).
12299                            if staged_deletes.contains(&(*table_id, r.row_id.0)) {
12300                                continue;
12301                            }
12302                            if let Some(theirs) = encode_composite_key(&uc.columns, &r.columns) {
12303                                if theirs == key {
12304                                    return Err(MongrelError::Conflict(format!(
12305                                        "UNIQUE constraint '{}' on table '{tname}' violated",
12306                                        uc.name
12307                                    )));
12308                                }
12309                            }
12310                        }
12311                    }
12312
12313                    // FK insert-side: parent must exist.
12314                    for fk in &schema.constraints.foreign_keys {
12315                        let Some(child_key) = encode_composite_key(&fk.columns, &cells_map) else {
12316                            continue; // NULL FK component → not checked (SQL).
12317                        };
12318                        let Some(parent_id) = table_ids_by_name.get(&fk.ref_table).copied() else {
12319                            return Err(MongrelError::InvalidArgument(format!(
12320                                "FOREIGN KEY '{}' references unknown table '{}'",
12321                                fk.name, fk.ref_table
12322                            )));
12323                        };
12324                        // S1B-003: hold a Shared parent-protection lock on the
12325                        // referenced key while checking existence, so a
12326                        // concurrent parent delete or key-changing update
12327                        // serializes against this insert.
12328                        self.acquire_fk_lock(
12329                            txn_id,
12330                            parent_id,
12331                            &child_key,
12332                            crate::locks::LockMode::Shared,
12333                            control,
12334                        )?;
12335                        let parent_rows = load_rows(parent_id)?;
12336                        let mut found = false;
12337                        for (row_index, r) in parent_rows.iter().enumerate() {
12338                            commit_prepare_checkpoint(control, row_index)?;
12339                            if staged_deletes.contains(&(parent_id, r.row_id.0)) {
12340                                continue;
12341                            }
12342                            if let Some(pkey) = encode_composite_key(&fk.ref_columns, &r.columns) {
12343                                if pkey == child_key {
12344                                    found = true;
12345                                    break;
12346                                }
12347                            }
12348                        }
12349                        // Final-write-set FK validation: a parent inserted in
12350                        // THIS transaction also satisfies the FK. This enables
12351                        // atomic parent+child batches and cyclical/mutual FK
12352                        // inserts within a single transaction — the child sees
12353                        // the staged parent put even though it is not committed
12354                        // yet.
12355                        if !found {
12356                            for (staged_index, (st_table, st_op)) in staging.iter().enumerate() {
12357                                commit_prepare_checkpoint(control, staged_index)?;
12358                                if *st_table != parent_id {
12359                                    continue;
12360                                }
12361                                if let Staged::Put(pcells)
12362                                | Staged::Update {
12363                                    new_row: pcells, ..
12364                                } = st_op
12365                                {
12366                                    let pmap: HashMap<u16, crate::memtable::Value> =
12367                                        pcells.iter().cloned().collect();
12368                                    if let Some(pkey) = encode_composite_key(&fk.ref_columns, &pmap)
12369                                    {
12370                                        if pkey == child_key {
12371                                            found = true;
12372                                            break;
12373                                        }
12374                                    }
12375                                }
12376                            }
12377                        }
12378                        if !found {
12379                            return Err(MongrelError::Conflict(format!(
12380                                "FOREIGN KEY '{}' on table '{tname}' has no matching parent in '{}'",
12381                                fk.name, fk.ref_table
12382                            )));
12383                        }
12384                    }
12385
12386                    // Parent-side ON UPDATE RESTRICT. CASCADE/SET NULL were
12387                    // expanded in Phase A; here the final child write set is
12388                    // known, so a child explicitly moved/deleted by this same
12389                    // transaction does not cause a false violation.
12390                    if let Staged::Update { row_id, .. } = op {
12391                        let parent_handle = self.table_by_id(*table_id)?;
12392                        let Some(old_parent) = parent_handle.lock().get(*row_id, snapshot) else {
12393                            continue;
12394                        };
12395                        for (child_id, child_name, child_schema) in &live {
12396                            for fk in &child_schema.constraints.foreign_keys {
12397                                if fk.ref_table != *tname || fk.on_update != FkAction::Restrict {
12398                                    continue;
12399                                }
12400                                let Some(old_key) =
12401                                    encode_composite_key(&fk.ref_columns, &old_parent.columns)
12402                                else {
12403                                    continue;
12404                                };
12405                                if encode_composite_key(&fk.ref_columns, &cells_map).as_deref()
12406                                    == Some(old_key.as_slice())
12407                                {
12408                                    continue;
12409                                }
12410                                // S1B-003: the referenced key is being changed —
12411                                // hold an Exclusive parent-protection lock
12412                                // against concurrent child inserts referencing
12413                                // the old key.
12414                                self.acquire_fk_lock(
12415                                    txn_id,
12416                                    *table_id,
12417                                    &old_key,
12418                                    crate::locks::LockMode::Exclusive,
12419                                    control,
12420                                )?;
12421                                for (child_index, child) in load_rows(*child_id)?.iter().enumerate()
12422                                {
12423                                    commit_prepare_checkpoint(control, child_index)?;
12424                                    if encode_composite_key(&fk.columns, &child.columns).as_deref()
12425                                        != Some(old_key.as_slice())
12426                                    {
12427                                        continue;
12428                                    }
12429                                    let replacement = staging.iter().find_map(|(id, op)| {
12430                                        if *id != *child_id {
12431                                            return None;
12432                                        }
12433                                        match op {
12434                                            Staged::Delete(id) if *id == child.row_id => Some(None),
12435                                            Staged::Update {
12436                                                row_id,
12437                                                new_row: cells,
12438                                                ..
12439                                            } if *row_id == child.row_id => {
12440                                                let map: HashMap<u16, Value> =
12441                                                    cells.iter().cloned().collect();
12442                                                Some(encode_composite_key(&fk.columns, &map))
12443                                            }
12444                                            _ => None,
12445                                        }
12446                                    });
12447                                    if replacement.is_some_and(|key| {
12448                                        key.as_deref() != Some(old_key.as_slice())
12449                                    }) {
12450                                        continue;
12451                                    }
12452                                    return Err(MongrelError::Conflict(format!(
12453                                        "FOREIGN KEY '{}' on table '{child_name}' restricts update (parent key referenced)",
12454                                        fk.name
12455                                    )));
12456                                }
12457                            }
12458                        }
12459                    }
12460                }
12461                Staged::Delete(rid) => {
12462                    // FK ON DELETE RESTRICT: a child row (whose FK action is
12463                    // RESTRICT) referencing this parent blocks the delete.
12464                    // CASCADE/SET NULL children were expanded in Phase A.
12465                    let parent_handle = self.table_by_id(*table_id)?;
12466                    let Some(parent_row) = parent_handle.lock().get(*rid, snapshot) else {
12467                        continue;
12468                    };
12469                    for (child_id, child_name, child_schema) in &live {
12470                        for fk in &child_schema.constraints.foreign_keys {
12471                            if fk.ref_table != *tname || fk.on_delete != FkAction::Restrict {
12472                                continue;
12473                            }
12474                            let Some(parent_key) =
12475                                encode_composite_key(&fk.ref_columns, &parent_row.columns)
12476                            else {
12477                                continue;
12478                            };
12479                            let child_rows = load_rows(*child_id)?;
12480                            for (row_index, r) in child_rows.iter().enumerate() {
12481                                commit_prepare_checkpoint(control, row_index)?;
12482                                // A child already being deleted by this txn
12483                                // (cascade/inline) is not a restrict violation.
12484                                if staged_deletes.contains(&(*child_id, r.row_id.0)) {
12485                                    continue;
12486                                }
12487                                if let Some(ck) = encode_composite_key(&fk.columns, &r.columns) {
12488                                    if ck == parent_key {
12489                                        return Err(MongrelError::Conflict(format!(
12490                                            "FOREIGN KEY '{}' on table '{child_name}' restricts delete (parent referenced)",
12491                                            fk.name
12492                                        )));
12493                                    }
12494                                }
12495                            }
12496                        }
12497                    }
12498                }
12499                Staged::Truncate => {
12500                    // Truncate is RESTRICT-only: reject if any child references
12501                    // this table (any FK action), since cascade-truncate is
12502                    // unsupported.
12503                    for (child_id, child_name, child_schema) in &live {
12504                        for fk in &child_schema.constraints.foreign_keys {
12505                            if fk.ref_table != *tname {
12506                                continue;
12507                            }
12508                            let child_rows = load_rows(*child_id)?;
12509                            if child_rows
12510                                .iter()
12511                                .any(|r| encode_composite_key(&fk.columns, &r.columns).is_some())
12512                            {
12513                                return Err(MongrelError::Conflict(format!(
12514                                    "FOREIGN KEY '{}' on table '{child_name}' restricts truncate of '{tname}'",
12515                                    fk.name
12516                                )));
12517                            }
12518                        }
12519                    }
12520                }
12521            }
12522        }
12523        Ok(())
12524    }
12525
12526    fn validate_write_permissions(
12527        &self,
12528        staging: &[(u64, crate::txn::Staged)],
12529        principal: Option<&crate::auth::Principal>,
12530        control: Option<&crate::ExecutionControl>,
12531    ) -> Result<()> {
12532        commit_prepare_checkpoint(control, 0)?;
12533        if principal.is_none() && !self.auth_state.require_auth() {
12534            return Ok(());
12535        }
12536        let principal = principal.ok_or(MongrelError::AuthRequired)?;
12537        let needs = summarize_write_permissions(staging);
12538        let catalog = self.catalog.read();
12539
12540        if needs.values().any(|need| need.truncate) {
12541            self.require_for(Some(principal), &crate::auth::Permission::Admin)?;
12542        }
12543        for (need_index, (table_id, need)) in needs.into_iter().enumerate() {
12544            commit_prepare_checkpoint(control, need_index)?;
12545            let entry = catalog
12546                .tables
12547                .iter()
12548                .find(|entry| {
12549                    entry.table_id == table_id
12550                        && matches!(entry.state, TableState::Live | TableState::Building { .. })
12551                })
12552                .ok_or_else(|| {
12553                    MongrelError::NotFound(format!(
12554                        "live table {table_id} not found during write validation"
12555                    ))
12556                })?;
12557            if matches!(entry.state, TableState::Building { .. }) {
12558                self.require_for(Some(principal), &crate::auth::Permission::Ddl)?;
12559                continue;
12560            }
12561            if need.insert {
12562                Self::require_columns_for_principal(
12563                    &entry.name,
12564                    &entry.schema,
12565                    crate::auth::ColumnOperation::Insert,
12566                    &need.insert_columns,
12567                    principal,
12568                )?;
12569            }
12570            if need.update {
12571                Self::require_columns_for_principal(
12572                    &entry.name,
12573                    &entry.schema,
12574                    crate::auth::ColumnOperation::Update,
12575                    &need.update_columns,
12576                    principal,
12577                )?;
12578            }
12579            if need.delete {
12580                self.require_for(
12581                    Some(principal),
12582                    &crate::auth::Permission::Delete {
12583                        table: entry.name.clone(),
12584                    },
12585                )?;
12586            }
12587        }
12588        Ok(())
12589    }
12590
12591    fn validate_security_writes(
12592        &self,
12593        staging: &[(u64, crate::txn::Staged)],
12594        read_epoch: Epoch,
12595        explicit_principal: Option<&crate::auth::Principal>,
12596        control: Option<&crate::ExecutionControl>,
12597    ) -> Result<()> {
12598        commit_prepare_checkpoint(control, 0)?;
12599        use crate::security::PolicyCommand;
12600        use crate::txn::Staged;
12601
12602        let catalog = self.catalog.read();
12603        if catalog.security.rls_tables.is_empty() {
12604            return Ok(());
12605        }
12606        let security = catalog.security.clone();
12607        let table_names = catalog
12608            .tables
12609            .iter()
12610            .filter(|entry| matches!(entry.state, TableState::Live))
12611            .map(|entry| (entry.table_id, entry.name.clone()))
12612            .collect::<HashMap<_, _>>();
12613        drop(catalog);
12614        if !staging.iter().any(|(table_id, _)| {
12615            table_names
12616                .get(table_id)
12617                .is_some_and(|table| security.rls_enabled(table))
12618        }) {
12619            return Ok(());
12620        }
12621        let principal = explicit_principal.ok_or(MongrelError::AuthRequired)?;
12622
12623        for (operation_index, (table_id, operation)) in staging.iter().enumerate() {
12624            commit_prepare_checkpoint(control, operation_index)?;
12625            let Some(table) = table_names.get(table_id) else {
12626                continue;
12627            };
12628            if !security.rls_enabled(table) || principal.is_admin {
12629                continue;
12630            }
12631            let denied = |command| MongrelError::PermissionDenied {
12632                required: match command {
12633                    PolicyCommand::Insert => crate::auth::Permission::Insert {
12634                        table: table.clone(),
12635                    },
12636                    PolicyCommand::Update => crate::auth::Permission::Update {
12637                        table: table.clone(),
12638                    },
12639                    PolicyCommand::Delete | PolicyCommand::All | PolicyCommand::Select => {
12640                        crate::auth::Permission::Delete {
12641                            table: table.clone(),
12642                        }
12643                    }
12644                },
12645                principal: principal.username.clone(),
12646            };
12647            match operation {
12648                Staged::Put(cells) => {
12649                    let mut row = crate::memtable::Row::new(RowId(0), Epoch(read_epoch.0));
12650                    row.columns.extend(cells.iter().cloned());
12651                    if !security.row_allowed(table, PolicyCommand::Insert, &row, principal, true) {
12652                        return Err(denied(PolicyCommand::Insert));
12653                    }
12654                }
12655                Staged::Update {
12656                    row_id,
12657                    new_row: cells,
12658                    ..
12659                } => {
12660                    let old = self
12661                        .table_by_id(*table_id)?
12662                        .lock()
12663                        .get(*row_id, self.snapshot_for_epoch(read_epoch))
12664                        .ok_or_else(|| {
12665                            MongrelError::NotFound(format!("row {} not found", row_id.0))
12666                        })?;
12667                    if !security.row_allowed(table, PolicyCommand::Update, &old, principal, false) {
12668                        return Err(denied(PolicyCommand::Update));
12669                    }
12670                    let mut new = crate::memtable::Row::new(*row_id, Epoch(read_epoch.0));
12671                    new.columns.extend(cells.iter().cloned());
12672                    if !security.row_allowed(table, PolicyCommand::Update, &new, principal, true) {
12673                        return Err(denied(PolicyCommand::Update));
12674                    }
12675                }
12676                Staged::Delete(row_id) => {
12677                    let old = self
12678                        .table_by_id(*table_id)?
12679                        .lock()
12680                        .get(*row_id, self.snapshot_for_epoch(read_epoch))
12681                        .ok_or_else(|| {
12682                            MongrelError::NotFound(format!("row {} not found", row_id.0))
12683                        })?;
12684                    if !security.row_allowed(table, PolicyCommand::Delete, &old, principal, false) {
12685                        return Err(denied(PolicyCommand::Delete));
12686                    }
12687                }
12688                Staged::Truncate => return Err(denied(PolicyCommand::Delete)),
12689            }
12690        }
12691        Ok(())
12692    }
12693
12694    /// Seal a transaction (spec §9.3):
12695    /// 1. Prepare — derive write keys, allocate row ids (brief table locks).
12696    /// 2. Sequencer — validate-first under the WAL mutex; abort on conflict
12697    ///    with no epoch consumed; assign epoch, append data records + TxnCommit,
12698    ///    group-sync, record conflict keys.
12699    /// 3. Publish — apply to tables, advance visible in-order.
12700    #[allow(clippy::too_many_arguments)]
12701    pub(crate) fn commit_transaction_with_external_states(
12702        &self,
12703        txn_id: u64,
12704        read_epoch: Epoch,
12705        staging: Vec<(u64, crate::txn::Staged)>,
12706        external_states: Vec<(String, Vec<u8>)>,
12707        materialized_view_updates: Vec<crate::catalog::MaterializedViewEntry>,
12708        security_principal: Option<crate::auth::Principal>,
12709        principal_catalog_bound: bool,
12710        external_trigger_bridge: Option<&dyn ExternalTriggerBridge>,
12711        context: crate::txn::TxnCommitContext,
12712    ) -> Result<(Epoch, Vec<RowId>)> {
12713        self.commit_transaction_with_external_states_inner(
12714            txn_id,
12715            read_epoch,
12716            staging,
12717            external_states,
12718            materialized_view_updates,
12719            security_principal,
12720            principal_catalog_bound,
12721            external_trigger_bridge,
12722            context,
12723            None,
12724            None,
12725        )
12726    }
12727
12728    #[allow(clippy::too_many_arguments)]
12729    pub(crate) fn commit_transaction_with_external_states_controlled(
12730        &self,
12731        txn_id: u64,
12732        read_epoch: Epoch,
12733        staging: Vec<(u64, crate::txn::Staged)>,
12734        external_states: Vec<(String, Vec<u8>)>,
12735        materialized_view_updates: Vec<crate::catalog::MaterializedViewEntry>,
12736        security_principal: Option<crate::auth::Principal>,
12737        principal_catalog_bound: bool,
12738        external_trigger_bridge: Option<&dyn ExternalTriggerBridge>,
12739        context: crate::txn::TxnCommitContext,
12740        control: &crate::ExecutionControl,
12741        before_commit: &mut dyn FnMut() -> Result<()>,
12742    ) -> Result<(Epoch, Vec<RowId>)> {
12743        self.commit_transaction_with_external_states_inner(
12744            txn_id,
12745            read_epoch,
12746            staging,
12747            external_states,
12748            materialized_view_updates,
12749            security_principal,
12750            principal_catalog_bound,
12751            external_trigger_bridge,
12752            context,
12753            Some(control),
12754            Some(before_commit),
12755        )
12756    }
12757
12758    #[allow(clippy::too_many_arguments)]
12759    fn commit_transaction_with_external_states_inner(
12760        &self,
12761        txn_id: u64,
12762        read_epoch: Epoch,
12763        mut staging: Vec<(u64, crate::txn::Staged)>,
12764        external_states: Vec<(String, Vec<u8>)>,
12765        materialized_view_updates: Vec<crate::catalog::MaterializedViewEntry>,
12766        mut security_principal: Option<crate::auth::Principal>,
12767        principal_catalog_bound: bool,
12768        external_trigger_bridge: Option<&dyn ExternalTriggerBridge>,
12769        context: crate::txn::TxnCommitContext,
12770        control: Option<&crate::ExecutionControl>,
12771        mut before_commit: Option<&mut dyn FnMut() -> Result<()>>,
12772    ) -> Result<(Epoch, Vec<RowId>)> {
12773        use crate::memtable::Row;
12774        use crate::txn::{Staged, StagedOp, WriteKey};
12775        use crate::wal::Op;
12776        use std::collections::hash_map::DefaultHasher;
12777        use std::hash::{Hash, Hasher};
12778        use std::sync::atomic::Ordering;
12779
12780        if txn_id == crate::wal::SYSTEM_TXN_ID {
12781            return Err(MongrelError::Full(
12782                "per-open transaction id namespace exhausted; reopen the database".into(),
12783            ));
12784        }
12785        if self.read_only {
12786            return Err(MongrelError::ReadOnlyReplica);
12787        }
12788        commit_prepare_checkpoint(control, 0)?;
12789        let observed_security_version = self.security_coordinator.version.load(Ordering::Acquire);
12790        self.refresh_security_catalog_if_stale(observed_security_version)?;
12791        let trigger_binding = trigger_catalog_binding(&self.catalog.read());
12792        if self.auth_state.require_auth() && security_principal.is_none() {
12793            return Err(MongrelError::AuthRequired);
12794        }
12795        {
12796            let catalog = self.catalog.read();
12797            if principal_catalog_bound
12798                || security_principal
12799                    .as_ref()
12800                    .is_some_and(|principal| principal.user_id != 0)
12801            {
12802                let principal = security_principal
12803                    .as_ref()
12804                    .ok_or(MongrelError::AuthRequired)?;
12805                security_principal =
12806                    Self::resolve_bound_principal_from_catalog(&catalog, principal);
12807                if security_principal.is_none() {
12808                    return Err(MongrelError::AuthRequired);
12809                }
12810            }
12811        }
12812        let _replication_guard = self.replication_barrier.read();
12813        if self.poisoned.load(Ordering::Relaxed) {
12814            return Err(MongrelError::Other(
12815                "database poisoned by fsync error".into(),
12816            ));
12817        }
12818        // S1A-004: admit the commit as one core operation (rejects once the
12819        // core is draining, closing, closed, or lifecycle-poisoned; the legacy
12820        // fsync-poison error above still wins for a WAL-poisoned core).
12821        let _operation = self.admit_operation()?;
12822
12823        // ── S1B-003: user transactions hold the schema barrier Shared for the
12824        // whole commit so DDL (Exclusive) excludes concurrent DML. Internal
12825        // commits (catalog backfills, external-table state — `context.state`
12826        // is `None`) skip it: they run under their DDL operation's own
12827        // Exclusive hold, and acquiring a second, differently-keyed hold here
12828        // would self-deadlock. The guard releases every lock this commit
12829        // acquired — schema barrier, unique claims, sequence barriers, FK
12830        // holds — on every exit path (S1B-004 step 12).
12831        if context.state.is_some() {
12832            self.acquire_txn_lock(
12833                txn_id,
12834                crate::locks::LockKey::schema_barrier(),
12835                crate::locks::LockMode::Shared,
12836                control,
12837            )?;
12838        }
12839        let _txn_lock_guard = TxnLockGuard {
12840            locks: &self.lock_manager,
12841            txn_id,
12842        };
12843
12844        // ── S1B-005: idempotency check BEFORE the proposal. A repeated key
12845        // with an identical request replays the original receipt without
12846        // re-executing; a different request under the same key conflicts; a
12847        // new key is reserved durably before any WAL record can become
12848        // durable, so a crash mid-commit fails closed on retry.
12849        let idempotency_request = match &context.idempotency {
12850            Some(request) => match self.idempotency.check_and_reserve(request)? {
12851                crate::txn::IdempotencyCheck::Replay(receipt) => {
12852                    let epoch = Epoch(receipt.log_position.index);
12853                    if let Some(state) = &context.state {
12854                        state.committed(receipt);
12855                    }
12856                    return Ok((epoch, Vec::new()));
12857                }
12858                crate::txn::IdempotencyCheck::Reserved => Some(request.clone()),
12859            },
12860            None => None,
12861        };
12862        // Release the reservation on every pre-receipt failure path.
12863        let mut idempotency_guard = idempotency_request.as_ref().map(|request| {
12864            crate::txn::IdempotencyReservationGuard::new(&self.idempotency, request.clone())
12865        });
12866        let mut external_states = dedup_external_states(external_states);
12867        if !external_states.is_empty() {
12868            let cat = self.catalog.read();
12869            for (name, _) in &external_states {
12870                if !cat.external_tables.iter().any(|entry| entry.name == *name) {
12871                    return Err(MongrelError::NotFound(format!(
12872                        "external table {name:?} not found"
12873                    )));
12874                }
12875            }
12876        }
12877        let prepared_materialized_views = {
12878            let mut deduplicated = HashMap::new();
12879            for (definition_index, definition) in materialized_view_updates.into_iter().enumerate()
12880            {
12881                commit_prepare_checkpoint(control, definition_index)?;
12882                if definition.name.is_empty() || definition.query.trim().is_empty() {
12883                    return Err(MongrelError::InvalidArgument(
12884                        "materialized view name and query must not be empty".into(),
12885                    ));
12886                }
12887                deduplicated.insert(definition.name.clone(), definition);
12888            }
12889            let catalog = self.catalog.read();
12890            let mut prepared = Vec::with_capacity(deduplicated.len());
12891            for (definition_index, definition) in deduplicated.into_values().enumerate() {
12892                commit_prepare_checkpoint(control, definition_index)?;
12893                let table_id = catalog
12894                    .live(&definition.name)
12895                    .ok_or_else(|| {
12896                        MongrelError::NotFound(format!(
12897                            "materialized view table {:?} not found",
12898                            definition.name
12899                        ))
12900                    })?
12901                    .table_id;
12902                prepared.push((table_id, definition));
12903            }
12904            prepared.sort_by(|left, right| left.1.name.cmp(&right.1.name));
12905            prepared
12906        };
12907
12908        // ── 1. Prepare: fill generated values, expand triggers, validate, then
12909        // derive write keys from the final atomic write set.
12910        self.fill_auto_increment_for_staging(txn_id, &mut staging, control)?;
12911        self.expand_table_triggers(
12912            txn_id,
12913            &mut staging,
12914            read_epoch,
12915            external_trigger_bridge,
12916            &mut external_states,
12917            control,
12918        )?;
12919        self.fill_auto_increment_for_staging(txn_id, &mut staging, control)?;
12920        external_states = dedup_external_states(external_states);
12921        let expected_external_generations = {
12922            let catalog = self.catalog.read();
12923            let mut generations = HashMap::with_capacity(external_states.len());
12924            for (name, _) in &external_states {
12925                let entry = catalog
12926                    .external_tables
12927                    .iter()
12928                    .find(|entry| entry.name == *name)
12929                    .ok_or_else(|| {
12930                        MongrelError::NotFound(format!("external table {name:?} not found"))
12931                    })?;
12932                generations.insert(name.clone(), entry.created_epoch);
12933            }
12934            generations
12935        };
12936
12937        // S1B-003: claim every unique key this transaction inserts (primary
12938        // keys and declared UNIQUE constraints) in Exclusive mode BEFORE
12939        // validation reads. Concurrent inserts of the same key serialize on
12940        // the claim instead of racing to a write-write conflict; the
12941        // first-committer-wins index below remains the safety net.
12942        self.acquire_unique_key_claims(txn_id, &staging, control)?;
12943        // Fail unauthorized source writes before constraint expansion reads
12944        // existing rows. Constraint-generated writes are checked again inside
12945        // `validate_constraints` before any embedding provider is invoked.
12946        self.validate_write_permissions(&staging, security_principal.as_ref(), control)?;
12947        self.validate_security_writes(&staging, read_epoch, security_principal.as_ref(), control)?;
12948        // Validate declarative constraints (unique / FK / check) under the read
12949        // snapshot, outside the WAL mutex. Trigger-produced writes are included
12950        // here, so the batch either satisfies every declared constraint or is
12951        // rejected atomically. This also materializes generated vectors after
12952        // all trigger and constraint-action writes exist, but before WAL append.
12953        self.validate_constraints(
12954            txn_id,
12955            &mut staging,
12956            read_epoch,
12957            security_principal.as_ref(),
12958            control,
12959        )?;
12960        let mut normalized = Vec::with_capacity(staging.len() * 2);
12961        for (staged_index, (table_id, op)) in staging.into_iter().enumerate() {
12962            commit_prepare_checkpoint(control, staged_index)?;
12963            match op {
12964                crate::txn::Staged::Update {
12965                    row_id,
12966                    new_row: cells,
12967                    ..
12968                } => {
12969                    normalized.push((table_id, crate::txn::Staged::Delete(row_id)));
12970                    normalized.push((table_id, crate::txn::Staged::Put(cells)));
12971                }
12972                op => normalized.push((table_id, op)),
12973            }
12974        }
12975        staging = normalized;
12976        let has_changes = !staging.is_empty()
12977            || !external_states.is_empty()
12978            || !prepared_materialized_views.is_empty();
12979        let truncated_tables: HashSet<u64> = staging
12980            .iter()
12981            .filter_map(|(table_id, op)| matches!(op, Staged::Truncate).then_some(*table_id))
12982            .collect();
12983
12984        let write_keys = {
12985            let cat = self.catalog.read();
12986            let mut keys: Vec<WriteKey> = Vec::new();
12987            for (staged_index, (table_id, staged)) in staging.iter().enumerate() {
12988                commit_prepare_checkpoint(control, staged_index)?;
12989                match staged {
12990                    Staged::Put(cells) => {
12991                        if let Some(entry) = cat.tables.iter().find(|t| t.table_id == *table_id) {
12992                            for col in &entry.schema.columns {
12993                                if col.flags.contains(crate::schema::ColumnFlags::PRIMARY_KEY) {
12994                                    if let Some((_, val)) =
12995                                        cells.iter().find(|(id, _)| *id == col.id)
12996                                    {
12997                                        let mut h = DefaultHasher::new();
12998                                        val.encode_key().hash(&mut h);
12999                                        keys.push(WriteKey::Unique {
13000                                            table_id: *table_id,
13001                                            index_id: 0,
13002                                            key_hash: h.finish(),
13003                                        });
13004                                    }
13005                                }
13006                            }
13007                            // Declared non-PK unique constraints register a
13008                            // `WriteKey::Unique` (namespace-separated from the
13009                            // PK's index_id==0 by setting the high bit) so two
13010                            // concurrent transactions inserting the same key
13011                            // cannot both commit. Rows with any NULL constrained
13012                            // column are skipped (SQL semantics).
13013                            if !entry.schema.constraints.uniques.is_empty() {
13014                                let cells_map: HashMap<u16, Value> =
13015                                    cells.iter().cloned().collect();
13016                                for uc in &entry.schema.constraints.uniques {
13017                                    if let Some(key_bytes) = crate::constraint::encode_composite_key(
13018                                        &uc.columns,
13019                                        &cells_map,
13020                                    ) {
13021                                        let mut h = DefaultHasher::new();
13022                                        key_bytes.hash(&mut h);
13023                                        keys.push(WriteKey::Unique {
13024                                            table_id: *table_id,
13025                                            index_id: uc.id | 0x8000,
13026                                            key_hash: h.finish(),
13027                                        });
13028                                    }
13029                                }
13030                            }
13031                        }
13032                    }
13033                    Staged::Delete(rid) => keys.push(WriteKey::Row {
13034                        table_id: *table_id,
13035                        row_id: rid.0,
13036                    }),
13037                    Staged::Truncate => keys.push(WriteKey::Table {
13038                        table_id: *table_id,
13039                    }),
13040                    Staged::Update { .. } => {
13041                        return Err(MongrelError::Other(
13042                            "transaction contains an unnormalized update during preparation".into(),
13043                        ));
13044                    }
13045                }
13046            }
13047            for (external_index, (name, _)) in external_states.iter().enumerate() {
13048                commit_prepare_checkpoint(control, external_index)?;
13049                let mut h = DefaultHasher::new();
13050                name.hash(&mut h);
13051                keys.push(WriteKey::Unique {
13052                    table_id: EXTERNAL_TABLE_ID,
13053                    index_id: 0,
13054                    key_hash: h.finish(),
13055                });
13056            }
13057            keys
13058        };
13059
13060        // Opportunistic pruning.
13061        let min_active = self.active_txns.min_read_epoch();
13062        if min_active < u64::MAX {
13063            self.conflicts.prune_below(Epoch(min_active));
13064        }
13065
13066        // S1B-002 (Serializable): SSI certification keys — every tracked point
13067        // read as a row key, every tracked predicate/range read as a table
13068        // key. A concurrent commit covering any of them after this
13069        // transaction's read epoch is an rw-antidependency dangerous
13070        // structure; certification aborts rather than allowing a
13071        // non-serializable interleaving.
13072        let ssi_keys = match context.isolation.canonical() {
13073            crate::txn::IsolationLevel::Serializable => {
13074                crate::txn::ssi_validation_keys(&context.read_set, &context.predicate_set)
13075            }
13076            _ => Vec::new(),
13077        };
13078
13079        // ── 1a. Pre-validate the full write-set OUTSIDE the sequencer (spec
13080        // §8.5, review fix #17). Snapshot the conflict-index version so the
13081        // sequencer only re-checks if new commits arrived in the interim.
13082        if self.conflicts.conflicts(&write_keys, read_epoch) {
13083            return Err(MongrelError::Conflict(
13084                "write-write conflict (pre-validate, first-committer-wins)".into(),
13085            ));
13086        }
13087        if !ssi_keys.is_empty() && self.conflicts.conflicts(&ssi_keys, read_epoch) {
13088            return Err(MongrelError::SerializationFailure {
13089                message: "a concurrent commit invalidated this transaction's reads (pre-validate)"
13090                    .into(),
13091            });
13092        }
13093        let pre_validate_version = self.conflicts.version();
13094
13095        // ── 1b. Spill: if a table's staged puts exceed the threshold, write a
13096        // uniform-epoch pending run (spec §8.5). Rows in the run are NOT
13097        // streamed as Put records; they are linked at publish time.
13098        let mut spilled: Vec<SpilledRun> = Vec::new();
13099        let mut spilled_tables: std::collections::HashSet<u64> = std::collections::HashSet::new();
13100        // Protect this txn's `_txn/<id>/` dir from a concurrent `gc()` for as long
13101        // as the spill runs are live (registered on first spill, dropped at the
13102        // end of this function on commit/abort/error).
13103        let mut spill_guard: Option<crate::retention::SpillGuard> = None;
13104        {
13105            let mut table_bytes: HashMap<u64, u64> = HashMap::new();
13106            let mut put_indexes: HashMap<u64, Vec<usize>> = HashMap::new();
13107            for (staged_index, (table_id, staged)) in staging.iter().enumerate() {
13108                commit_prepare_checkpoint(control, staged_index)?;
13109                if let Staged::Put(cells) = staged {
13110                    let bytes = cells.iter().fold(32_u64, |bytes, (_, value)| {
13111                        bytes.saturating_add(value.estimated_bytes())
13112                    });
13113                    let table_bytes = table_bytes.entry(*table_id).or_default();
13114                    *table_bytes = table_bytes.saturating_add(bytes);
13115                    put_indexes.entry(*table_id).or_default().push(staged_index);
13116                }
13117            }
13118            let tables = self.tables.read();
13119            for (table_index, (&table_id, &bytes)) in table_bytes.iter().enumerate() {
13120                commit_prepare_checkpoint(control, table_index)?;
13121                if bytes
13122                    <= self
13123                        .spill_threshold
13124                        .load(std::sync::atomic::Ordering::Relaxed)
13125                {
13126                    continue;
13127                }
13128                let Some(handle) = tables.get(&table_id) else {
13129                    continue;
13130                };
13131                spill_guard.get_or_insert_with(|| self.active_spills.register(txn_id));
13132                let mut t = handle.lock();
13133                let tdir = t.table_dir().to_path_buf();
13134                let txn_dir = tdir.join("_txn").join(txn_id.to_string());
13135                std::fs::create_dir_all(&txn_dir)?;
13136                let run_id = t.alloc_run_id()? as u128;
13137                let pending_path = txn_dir.join(format!("r-{run_id}.sr"));
13138                let final_path = t.run_path(run_id as u64);
13139
13140                let mut rows: Vec<Row> = Vec::new();
13141                for (put_index, staged_index) in put_indexes[&table_id].iter().enumerate() {
13142                    commit_prepare_checkpoint(control, put_index)?;
13143                    let Staged::Put(cells) = &mut staging[*staged_index].1 else {
13144                        return Err(MongrelError::Other(
13145                            "transaction put index no longer references a put".into(),
13146                        ));
13147                    };
13148                    t.validate_cells_not_null(cells)?;
13149                    let row_id = t.alloc_row_id()?;
13150                    let mut row = Row::new(row_id, Epoch(0));
13151                    row.columns.extend(std::mem::take(cells));
13152                    rows.push(row);
13153                }
13154                let schema = t.schema_ref().clone();
13155                let kek = t.kek_ref().cloned();
13156                let specs = t.indexable_column_specs();
13157                drop(t);
13158
13159                let mut writer = crate::sorted_run::RunWriter::new(&schema, run_id, Epoch(0), 0)
13160                    .uniform_epoch(true);
13161                if let Some(ref kek) = kek {
13162                    writer = writer.with_encryption(kek.as_ref(), specs);
13163                }
13164                commit_prepare_checkpoint(control, 0)?;
13165                let header = writer.write(&pending_path, &rows)?;
13166                commit_prepare_checkpoint(control, 0)?;
13167                let row_count = header.row_count;
13168                let min_rid = rows.first().map(|r| r.row_id.0).unwrap_or(0);
13169                let max_rid = rows.last().map(|r| r.row_id.0).unwrap_or(0);
13170
13171                spilled.push(SpilledRun {
13172                    table_id,
13173                    run_id,
13174                    pending_path,
13175                    final_path,
13176                    rows,
13177                    row_count,
13178                    min_rid,
13179                    max_rid,
13180                    content_hash: header.content_hash,
13181                });
13182                spilled_tables.insert(table_id);
13183            }
13184        }
13185
13186        // Test seam: let a test race `gc()` against this in-flight spill.
13187        if spill_guard.is_some() {
13188            if let Some(hook) = self.spill_hook.lock().as_ref() {
13189                hook();
13190            }
13191        }
13192
13193        // ── 1c. Pre-build non-spilled put rows OUTSIDE the WAL critical section.
13194        // Allocating row ids + building the rows here (lock order: table handle →
13195        // nothing) means the sequencer never locks a table handle while holding
13196        // the shared-WAL mutex. That matters because `Table::commit`/`flush` lock
13197        // the table handle THEN the shared WAL; if the sequencer did the reverse
13198        // (WAL then handle) the two paths would deadlock (review fix: B1).
13199        // Aligned 1:1 with `staging`; `None` for deletes and spilled puts.
13200        // Row ids are allocated here, before the sequencer's delta conflict
13201        // re-check, so a losing txn leaks the ids it reserved — harmless, the
13202        // u64 row-id space is monotonic and gaps are expected (spills do the same).
13203        let mut prebuilt: Vec<Option<Row>> = std::iter::repeat_with(|| None)
13204            .take(staging.len())
13205            .collect();
13206        let mut delete_images: Vec<Option<Row>> = std::iter::repeat_with(|| None)
13207            .take(staging.len())
13208            .collect();
13209        {
13210            let mut indexes_by_table: HashMap<u64, Vec<usize>> = HashMap::new();
13211            for (index, (table_id, staged)) in staging.iter().enumerate() {
13212                commit_prepare_checkpoint(control, index)?;
13213                if matches!(staged, Staged::Delete(_))
13214                    || matches!(staged, Staged::Put(_) if !spilled_tables.contains(table_id))
13215                {
13216                    indexes_by_table.entry(*table_id).or_default().push(index);
13217                }
13218            }
13219            let tables = self.tables.read();
13220            for (table_index, (table_id, indexes)) in indexes_by_table.into_iter().enumerate() {
13221                commit_prepare_checkpoint(control, table_index)?;
13222                let handle = tables.get(&table_id).ok_or_else(|| {
13223                    MongrelError::NotFound(format!("table {table_id} not mounted"))
13224                })?;
13225                #[cfg(test)]
13226                PREBUILD_TABLE_LOCKS.with(|count| count.set(count.get() + 1));
13227                let mut t = handle.lock();
13228                for (prepare_index, index) in indexes.into_iter().enumerate() {
13229                    commit_prepare_checkpoint(control, prepare_index)?;
13230                    match &staging[index].1 {
13231                        Staged::Put(cells) if !spilled_tables.contains(&table_id) => {
13232                            t.validate_cells_not_null(cells)?;
13233                            let mut row = Row::new(t.alloc_row_id()?, Epoch(0));
13234                            for (column, value) in cells {
13235                                row.columns.insert(*column, value.clone());
13236                            }
13237                            prebuilt[index] = Some(row);
13238                        }
13239                        Staged::Delete(row_id) => {
13240                            delete_images[index] =
13241                                t.get(*row_id, self.snapshot_for_epoch(read_epoch));
13242                        }
13243                        Staged::Put(_) | Staged::Truncate => {}
13244                        Staged::Update { .. } => {
13245                            return Err(MongrelError::Other(
13246                                "transaction contains an unnormalized update during row preparation"
13247                                    .into(),
13248                            ));
13249                        }
13250                    }
13251                }
13252            }
13253        }
13254
13255        // Finish every fallible index read before the commit marker can become
13256        // durable. Post-durable row/run metadata application is then entirely
13257        // in-memory and cannot stop halfway through a multi-table publish.
13258        let prepared_table_handles = {
13259            let table_ids: HashSet<u64> = staging.iter().map(|(table_id, _)| *table_id).collect();
13260            let put_table_ids: HashSet<u64> = staging
13261                .iter()
13262                .filter_map(|(table_id, staged)| {
13263                    matches!(staged, Staged::Put(_)).then_some(*table_id)
13264                })
13265                .collect();
13266            let tables = self.tables.read();
13267            let mut handles = HashMap::with_capacity(table_ids.len());
13268            for (table_index, table_id) in table_ids.into_iter().enumerate() {
13269                commit_prepare_checkpoint(control, table_index)?;
13270                let handle = tables.get(&table_id).ok_or_else(|| {
13271                    MongrelError::NotFound(format!("table {table_id} not mounted"))
13272                })?;
13273                if put_table_ids.contains(&table_id) {
13274                    match control {
13275                        Some(control) => {
13276                            handle.lock().prepare_durable_publish_controlled(control)?
13277                        }
13278                        None => handle.lock().prepare_durable_publish()?,
13279                    }
13280                }
13281                handles.insert(table_id, handle.clone());
13282            }
13283            handles
13284        };
13285
13286        // Link large-transaction spill files before WAL durability. The guard
13287        // restores their pending names on every error before WAL append begins;
13288        // publication only attaches already-present files in memory.
13289        let mut prepared_run_links = PreparedRunLinks::prepare(&spilled)?;
13290
13291        let mut spilled_row_ids: HashMap<u64, VecDeque<RowId>> = spilled
13292            .iter()
13293            .map(|run| {
13294                (
13295                    run.table_id,
13296                    run.rows.iter().map(|row| row.row_id).collect(),
13297                )
13298            })
13299            .collect();
13300        let committed_row_ids = staging
13301            .iter()
13302            .enumerate()
13303            .filter_map(|(index, (table_id, staged))| {
13304                if !matches!(staged, Staged::Put(_)) {
13305                    return None;
13306                }
13307                prebuilt[index].as_ref().map(|row| row.row_id).or_else(|| {
13308                    spilled_row_ids
13309                        .get_mut(table_id)
13310                        .and_then(VecDeque::pop_front)
13311                })
13312            })
13313            .collect();
13314
13315        let mut prepared_external = Vec::with_capacity(external_states.len());
13316        for (external_index, (name, state)) in external_states.iter().enumerate() {
13317            commit_prepare_checkpoint(control, external_index)?;
13318            let pending = prepare_external_state_file(&self.root, name, state, txn_id)?;
13319            prepared_external.push((name.clone(), state.clone(), pending));
13320        }
13321
13322        // ── 2. Sequencer: validate-first → assign → append → sync → record ──
13323        //
13324        // The schema barrier's Shared hold covered the prepare/validate phases
13325        // above (every catalog, constraint, and claim read ran under a stable
13326        // schema); the publish below is guarded by the catalog-generation
13327        // check, exactly as before S1B-003. Release the barrier here — before
13328        // the sequencer — so a DDL waiting on its Exclusive hold may proceed
13329        // while this commit publishes (the generation check resolves that
13330        // race). Unique/sequence/FK claims stay held until the commit ends.
13331        if context.state.is_some() {
13332            self.lock_manager
13333                .release(txn_id, &crate::locks::LockKey::schema_barrier());
13334        }
13335        let added_runs: Vec<crate::wal::AddedRun> = spilled
13336            .iter()
13337            .map(|s| crate::wal::AddedRun {
13338                table_id: s.table_id,
13339                run_id: s.run_id,
13340                row_count: s.row_count,
13341                level: 0,
13342                min_row_id: s.min_rid,
13343                max_row_id: s.max_rid,
13344                content_hash: s.content_hash,
13345            })
13346            .collect();
13347        if let Some(hook) = self.catalog_commit_hook.lock().as_ref() {
13348            hook();
13349        }
13350        // Lock order: security gate -> commit lock -> shared WAL -> table locks.
13351        // Security mutations cannot overtake an authorized commit before its
13352        // commit marker is durable.
13353        let security_guard = self.security_coordinator.gate.read();
13354        if self.security_coordinator.version.load(Ordering::Acquire) != observed_security_version {
13355            return Err(MongrelError::Conflict(
13356                "security policy changed during write".into(),
13357            ));
13358        }
13359        if spill_guard.is_some() {
13360            if let Some(hook) = self.security_commit_hook.lock().as_ref() {
13361                hook();
13362            }
13363        }
13364        let commit_guard = self.commit_lock.lock();
13365        let catalog_generation_result = (|| {
13366            {
13367                let catalog = self.catalog.read();
13368                for table_id in prepared_table_handles.keys() {
13369                    let is_current = catalog.tables.iter().any(|entry| {
13370                        entry.table_id == *table_id
13371                            && matches!(entry.state, TableState::Live | TableState::Building { .. })
13372                    });
13373                    if !is_current {
13374                        return Err(MongrelError::Conflict(format!(
13375                            "table {table_id} changed during transaction preparation"
13376                        )));
13377                    }
13378                }
13379                for (name, created_epoch) in &expected_external_generations {
13380                    let current = catalog
13381                        .external_tables
13382                        .iter()
13383                        .find(|entry| entry.name == *name)
13384                        .map(|entry| entry.created_epoch);
13385                    if current != Some(*created_epoch) {
13386                        return Err(MongrelError::Conflict(format!(
13387                            "external table {name:?} changed during transaction preparation"
13388                        )));
13389                    }
13390                }
13391                for (table_id, definition) in &prepared_materialized_views {
13392                    let current = catalog.live(&definition.name).map(|entry| entry.table_id);
13393                    if current != Some(*table_id) {
13394                        return Err(MongrelError::Conflict(format!(
13395                            "materialized view {:?} changed during transaction preparation",
13396                            definition.name
13397                        )));
13398                    }
13399                }
13400                if trigger_catalog_binding(&catalog) != trigger_binding {
13401                    return Err(MongrelError::Conflict(
13402                        "trigger or referenced table generation changed during transaction preparation"
13403                            .into(),
13404                    ));
13405                }
13406            }
13407            let tables = self.tables.read();
13408            for (table_id, prepared) in &prepared_table_handles {
13409                if !tables
13410                    .get(table_id)
13411                    .is_some_and(|current| current.ptr_eq(prepared))
13412                {
13413                    return Err(MongrelError::Conflict(format!(
13414                        "table {table_id} mount changed during transaction preparation"
13415                    )));
13416                }
13417            }
13418            Ok(())
13419        })();
13420        if let Err(error) = catalog_generation_result {
13421            drop(commit_guard);
13422            for (_, _, pending) in &prepared_external {
13423                let _ = std::fs::remove_file(pending);
13424            }
13425            return Err(error);
13426        }
13427        // The commit lock keeps the next epoch stable while logical spill
13428        // records are serialized. Build them before taking the shared WAL
13429        // lock, and cap their aggregate memory/WAL footprint.
13430        let new_epoch = self.epoch.assigned().next();
13431        // S1B-004 step 5 / P0.5: allocate the commit HLC under the sequencer
13432        // lock *before* durable row payloads are encoded so every Put /
13433        // SpilledRows version carries the same commit_ts as the receipt.
13434        // Spec §8.2: strictly greater than every participant read/write
13435        // timestamp captured at `begin`.
13436        let commit_ts = match context.read_ts {
13437            Some(read_ts) => self.hlc.commit_timestamp([read_ts]),
13438            None => self.hlc.now().map_err(|skew| {
13439                MongrelError::Other(format!(
13440                    "clock skew rejected commit timestamp allocation: {skew}"
13441                ))
13442            })?,
13443        };
13444        let mut spilled_wal_bytes = 0;
13445        let mut spilled_wal_records = Vec::<(u64, Op)>::new();
13446        let spill_prepare = (|| {
13447            for run in &mut spilled {
13448                for row in &mut run.rows {
13449                    row.committed_epoch = new_epoch;
13450                    row.commit_ts = Some(commit_ts);
13451                }
13452                for rows in encode_spilled_row_chunks(
13453                    &run.rows,
13454                    &mut spilled_wal_bytes,
13455                    SPILLED_WAL_TOTAL_MAX_BYTES,
13456                    control,
13457                )? {
13458                    spilled_wal_records.push((
13459                        run.table_id,
13460                        Op::SpilledRows {
13461                            table_id: run.table_id,
13462                            rows,
13463                        },
13464                    ));
13465                }
13466            }
13467            Result::<()>::Ok(())
13468        })();
13469        if let Err(error) = spill_prepare {
13470            for (_, _, pending) in &prepared_external {
13471                let _ = std::fs::remove_file(pending);
13472            }
13473            return Err(error);
13474        }
13475        let (
13476            new_epoch,
13477            mut _epoch_guard,
13478            applies,
13479            committed_materialized_views,
13480            commit_seq,
13481            commit_ts,
13482        ) = {
13483            let mut wal = self.shared_wal.lock();
13484
13485            // Re-check only if the conflict index advanced since pre-validation
13486            // (bounded delta — spec §8.5, review fix #17). If the version is
13487            // unchanged, the pre-check result is still valid and the sequencer
13488            // does O(1) work regardless of write-set size.
13489            if self.conflicts.version() != pre_validate_version {
13490                if self.conflicts.conflicts(&write_keys, read_epoch) {
13491                    // Abort: this txn assigned no epoch yet. The prepared-run guard
13492                    // restores final run names to their pending paths on return.
13493                    drop(wal);
13494                    for (_, _, pending) in &prepared_external {
13495                        let _ = std::fs::remove_file(pending);
13496                    }
13497                    return Err(MongrelError::Conflict(
13498                        "write-write conflict (sequencer delta re-check)".into(),
13499                    ));
13500                }
13501                if !ssi_keys.is_empty() && self.conflicts.conflicts(&ssi_keys, read_epoch) {
13502                    // S1B-002 dangerous structure: a commit that landed between
13503                    // the pre-check and the sequencer invalidated a tracked
13504                    // read. Abort before assigning any epoch.
13505                    drop(wal);
13506                    for (_, _, pending) in &prepared_external {
13507                        let _ = std::fs::remove_file(pending);
13508                    }
13509                    return Err(MongrelError::SerializationFailure {
13510                        message:
13511                            "a concurrent commit invalidated this transaction's reads (sequencer re-check)"
13512                                .into(),
13513                    });
13514                }
13515            }
13516
13517            if let Some(control) = control {
13518                if let Err(error) = control.checkpoint() {
13519                    drop(wal);
13520                    for (_, _, pending) in &prepared_external {
13521                        let _ = std::fs::remove_file(pending);
13522                    }
13523                    return Err(error);
13524                }
13525            }
13526            let mut applies = Vec::<TableApplyBatch>::new();
13527            let mut apply_indexes = HashMap::<u64, usize>::new();
13528            let mut committed_materialized_views = Vec::new();
13529            let mut wal_records = spilled_wal_records;
13530
13531            let mut index = 0;
13532            while index < staging.len() {
13533                let table_id = staging[index].0;
13534                let handle = prepared_table_handles
13535                    .get(&table_id)
13536                    .cloned()
13537                    .ok_or_else(|| {
13538                        MongrelError::NotFound(format!("table {table_id} not prepared"))
13539                    })?;
13540                let batch_index = *apply_indexes.entry(table_id).or_insert_with(|| {
13541                    let index = applies.len();
13542                    applies.push(TableApplyBatch {
13543                        table_id,
13544                        handle,
13545                        ops: Vec::new(),
13546                    });
13547                    index
13548                });
13549
13550                // Skip puts for tables that were spilled — their data is in a
13551                // pending run, not in streamed Put records.
13552                if spilled_tables.contains(&table_id) && matches!(&staging[index].1, Staged::Put(_))
13553                {
13554                    index += 1;
13555                    continue;
13556                }
13557
13558                match &staging[index].1 {
13559                    Staged::Put(_) => {
13560                        let mut rows = Vec::new();
13561                        while index < staging.len()
13562                            && staging[index].0 == table_id
13563                            && matches!(&staging[index].1, Staged::Put(_))
13564                        {
13565                            let mut row = prebuilt[index].take().ok_or_else(|| {
13566                                MongrelError::Other(
13567                                    "transaction prepare lost a prebuilt put row".into(),
13568                                )
13569                            })?;
13570                            row.committed_epoch = new_epoch;
13571                            row.commit_ts = Some(commit_ts);
13572                            rows.push(row);
13573                            index += 1;
13574                        }
13575                        let payload = bincode::serialize(&rows)
13576                            .map_err(|e| MongrelError::Other(format!("row serialize: {e}")))?;
13577                        wal_records.push((
13578                            table_id,
13579                            Op::Put {
13580                                table_id,
13581                                rows: payload,
13582                            },
13583                        ));
13584                        applies[batch_index].ops.push(StagedOp::Put(rows));
13585                    }
13586                    Staged::Delete(_) => {
13587                        let mut row_ids = Vec::new();
13588                        while index < staging.len()
13589                            && staging[index].0 == table_id
13590                            && matches!(&staging[index].1, Staged::Delete(_))
13591                        {
13592                            let Staged::Delete(row_id) = &staging[index].1 else {
13593                                return Err(MongrelError::Other(
13594                                    "transaction delete batch changed during WAL preparation"
13595                                        .into(),
13596                                ));
13597                            };
13598                            if let Some(before) = &delete_images[index] {
13599                                wal_records.push((
13600                                    table_id,
13601                                    Op::BeforeImage {
13602                                        table_id,
13603                                        row_id: *row_id,
13604                                        row: bincode::serialize(before).map_err(|error| {
13605                                            MongrelError::Other(format!(
13606                                                "before-image serialize: {error}"
13607                                            ))
13608                                        })?,
13609                                    },
13610                                ));
13611                            }
13612                            row_ids.push(*row_id);
13613                            index += 1;
13614                        }
13615                        wal_records.push((
13616                            table_id,
13617                            Op::Delete {
13618                                table_id,
13619                                row_ids: row_ids.clone(),
13620                            },
13621                        ));
13622                        applies[batch_index].ops.push(StagedOp::Delete(row_ids));
13623                    }
13624                    Staged::Truncate => {
13625                        wal_records.push((table_id, Op::TruncateTable { table_id }));
13626                        applies[batch_index].ops.push(StagedOp::Truncate);
13627                        index += 1;
13628                    }
13629                    Staged::Update { .. } => {
13630                        return Err(MongrelError::Other(
13631                            "transaction contains an unnormalized update at the sequencer".into(),
13632                        ));
13633                    }
13634                }
13635            }
13636
13637            for (name, state, _) in &prepared_external {
13638                wal_records.push((
13639                    EXTERNAL_TABLE_ID,
13640                    Op::ExternalTableState {
13641                        name: name.clone(),
13642                        state: state.clone(),
13643                    },
13644                ));
13645            }
13646
13647            for (table_id, definition) in &prepared_materialized_views {
13648                let mut definition = definition.clone();
13649                definition.last_refresh_epoch = new_epoch.0;
13650                wal_records.push((
13651                    *table_id,
13652                    Op::Ddl(crate::wal::DdlOp::SetMaterializedView {
13653                        name: definition.name.clone(),
13654                        definition_json: crate::wal::DdlOp::encode_materialized_view(&definition)?,
13655                    }),
13656                ));
13657                committed_materialized_views.push(definition);
13658            }
13659            if !committed_materialized_views.is_empty() {
13660                let mut next_catalog = self.catalog.read().clone();
13661                for definition in &committed_materialized_views {
13662                    if let Some(existing) = next_catalog
13663                        .materialized_views
13664                        .iter_mut()
13665                        .find(|existing| existing.name == definition.name)
13666                    {
13667                        *existing = definition.clone();
13668                    } else {
13669                        next_catalog.materialized_views.push(definition.clone());
13670                    }
13671                }
13672                next_catalog.db_epoch = next_catalog.db_epoch.max(new_epoch.0);
13673                wal_records.push((
13674                    WAL_TABLE_ID,
13675                    Op::Ddl(crate::wal::DdlOp::CatalogSnapshot {
13676                        catalog_json: crate::wal::DdlOp::encode_catalog(&next_catalog)?,
13677                    }),
13678                ));
13679            }
13680
13681            if let Some(control) = control {
13682                if let Err(error) = control.checkpoint() {
13683                    drop(wal);
13684                    for (_, _, pending) in &prepared_external {
13685                        let _ = std::fs::remove_file(pending);
13686                    }
13687                    return Err(error);
13688                }
13689            }
13690            if let Some(before_commit) = before_commit.as_mut() {
13691                if let Err(error) = before_commit() {
13692                    drop(wal);
13693                    for (_, _, pending) in &prepared_external {
13694                        let _ = std::fs::remove_file(pending);
13695                    }
13696                    return Err(error);
13697                }
13698            }
13699
13700            let assigned_epoch = self.epoch.bump_assigned();
13701            let _epoch_guard = EpochGuard::new(self.epoch.as_ref(), assigned_epoch);
13702            if assigned_epoch != new_epoch {
13703                for (_, _, pending) in &prepared_external {
13704                    let _ = std::fs::remove_file(pending);
13705                }
13706                return Err(MongrelError::Conflict(
13707                    "commit epoch changed while sequencer lock was held".into(),
13708                ));
13709            }
13710
13711            // S1B-004 step 6: enter commit-critical before the proposal can
13712            // become durable. The HLC commit_ts was allocated above under the
13713            // commit lock so every row payload already carries it.
13714            // FND-006: `txn.decision.before` fires immediately before the
13715            // durable commit decision (enter CommitCritical / WAL proposal).
13716            // A Fail aborts while still Preparing — nothing durable yet.
13717            if let Err(fault) = mongreldb_fault::inject("txn.decision.before") {
13718                for (_, _, pending) in &prepared_external {
13719                    let _ = std::fs::remove_file(pending);
13720                }
13721                return Err(crate::commit_log::fault_as_io(fault));
13722            }
13723            if let Some(state) = &context.state {
13724                state.enter_commit_critical();
13725            }
13726
13727            // From this point the outcome can become ambiguous. Keep prepared
13728            // spill files at the final names referenced by a possibly durable
13729            // commit marker; orphan cleanup is safe when the append did fail.
13730            prepared_run_links.disarm();
13731
13732            // FND-004 (spec §9.4): the commit log owns the append step for the
13733            // transaction command (records + commit marker). The commit path
13734            // proposes through it; durability and the receipt follow below.
13735            let commit_seq = self
13736                .standalone_commit_log
13737                .append_transaction(
13738                    &mut wal,
13739                    txn_id,
13740                    new_epoch,
13741                    commit_ts,
13742                    wal_records,
13743                    &added_runs,
13744                )
13745                .map_err(|error| self.commit_outcome_unknown(new_epoch, error))?;
13746
13747            // Record the conflict + assign the epoch under the WAL lock so commit
13748            // order == WAL append order, but DO NOT fsync here (P3.2): the fsync
13749            // moves out of this critical section to the group-commit coordinator
13750            // so concurrent committers share a single leader fsync.
13751            self.conflicts.record(&write_keys, new_epoch);
13752            (
13753                new_epoch,
13754                _epoch_guard,
13755                applies,
13756                committed_materialized_views,
13757                commit_seq,
13758                commit_ts,
13759            )
13760        };
13761        drop(commit_guard);
13762
13763        // ── 2b. Durability: one leader fsync serves this whole batch (P3.2). ──
13764        let receipt =
13765            self.await_durable_commit_with_ts(txn_id, commit_seq, new_epoch, commit_ts)?;
13766        drop(security_guard);
13767
13768        // ── S1B-005: record the receipt against the reserved idempotency key
13769        // (durable before the caller can observe success), so a repeated key
13770        // with an identical request replays this receipt.
13771        if let Some(request) = &idempotency_request {
13772            if let Err(error) =
13773                self.idempotency
13774                    .complete(request, txn_id, new_epoch, receipt.commit_ts)
13775            {
13776                return Err(MongrelError::DurableCommit {
13777                    epoch: new_epoch.0,
13778                    message: format!("idempotency record was not durable: {error}"),
13779                });
13780            }
13781            if let Some(guard) = idempotency_guard.as_mut() {
13782                guard.disarm();
13783            }
13784        }
13785
13786        // ── 3. Publish: apply non-spilled ops + link spilled runs ──
13787        let publish_result: Result<()> = {
13788            let mut first_error = None;
13789            let mut spilled_by_table: HashMap<u64, Vec<&SpilledRun>> = HashMap::new();
13790            for run in &spilled {
13791                spilled_by_table.entry(run.table_id).or_default().push(run);
13792            }
13793            let mut modified_tables = Vec::with_capacity(applies.len());
13794            // Apply every table completely before any fallible manifest write.
13795            // The visible epoch remains unchanged until all tables are coherent.
13796            for batch in applies {
13797                #[cfg(test)]
13798                PUBLISH_TABLE_LOCKS.with(|count| count.set(count.get() + 1));
13799                let mut t = batch.handle.lock();
13800                for op in batch.ops {
13801                    match op {
13802                        StagedOp::Put(rows) => t.apply_put_rows_prepared(rows),
13803                        StagedOp::Delete(row_ids) => {
13804                            for row_id in row_ids {
13805                                t.apply_delete_at(row_id, new_epoch, Some(commit_ts));
13806                            }
13807                        }
13808                        StagedOp::Truncate => t.apply_truncate(new_epoch),
13809                    }
13810                }
13811                if let Some(runs) = spilled_by_table.remove(&batch.table_id) {
13812                    for run in runs {
13813                        t.link_run(crate::manifest::RunRef {
13814                            run_id: run.run_id,
13815                            level: 0,
13816                            epoch_created: new_epoch.0,
13817                            row_count: run.row_count,
13818                        });
13819                        t.apply_run_metadata_prepared(&run.rows)?;
13820                        if truncated_tables.contains(&batch.table_id) {
13821                            // TRUNCATE + spilled puts fully describe this table at
13822                            // the commit epoch. Endorse the epoch so clean-reopen
13823                            // recovery does not replay the truncate over the
13824                            // already-linked replacement run.
13825                            t.set_flushed_epoch(new_epoch);
13826                        }
13827                    }
13828                }
13829                t.invalidate_pending_cache();
13830                drop(t);
13831                modified_tables.push(batch.handle);
13832            }
13833
13834            // Checkpoint only after every live table carries the durable state.
13835            // Continue after one checkpoint failure so runtime publication stays
13836            // all-or-nothing; WAL recovery repairs failed files on reopen.
13837            for handle in modified_tables {
13838                #[cfg(test)]
13839                COMMIT_MANIFEST_WRITES.with(|count| count.set(count.get() + 1));
13840                if let Err(error) = handle.lock().persist_manifest(new_epoch) {
13841                    first_error.get_or_insert(error);
13842                }
13843            }
13844            for (name, _, pending) in &prepared_external {
13845                if let Err(error) = publish_external_state_file(&self.root, name, pending) {
13846                    first_error.get_or_insert(error);
13847                }
13848            }
13849            if !committed_materialized_views.is_empty() {
13850                let mut next_catalog = self.catalog.read().clone();
13851                for definition in committed_materialized_views {
13852                    if let Some(existing) = next_catalog
13853                        .materialized_views
13854                        .iter_mut()
13855                        .find(|existing| existing.name == definition.name)
13856                    {
13857                        *existing = definition;
13858                    } else {
13859                        next_catalog.materialized_views.push(definition);
13860                    }
13861                }
13862                next_catalog.db_epoch = next_catalog.db_epoch.max(new_epoch.0);
13863                if let Err(error) = self.checkpoint_catalog_after_durable(next_catalog) {
13864                    first_error.get_or_insert(error);
13865                }
13866            }
13867            match first_error {
13868                Some(error) => Err(error),
13869                None => Ok(()),
13870            }
13871        };
13872
13873        if has_changes {
13874            let _ = self.change_wake.send(());
13875        }
13876        self.finish_durable_publish(new_epoch, &mut _epoch_guard, &receipt, publish_result)?;
13877        // S1B-001: the commit is durable and published — record the receipt on
13878        // the formal state. (Post-publish errors above return `DurableCommit`
13879        // and deliberately leave the state `CommitCritical`.)
13880        if let Some(state) = &context.state {
13881            state.committed(receipt.clone());
13882        }
13883        // FND-006: `txn.decision.after` fires only after the decision is
13884        // durable and the Committed receipt is published. A Fail must not
13885        // undo the decision — surface as DurableCommit (post-fence).
13886        mongreldb_fault::inject("txn.decision.after").map_err(|fault| {
13887            MongrelError::DurableCommit {
13888                epoch: new_epoch.0,
13889                message: fault.to_string(),
13890            }
13891        })?;
13892        Ok((new_epoch, committed_row_ids))
13893    }
13894
13895    /// Build an HLC-authoritative snapshot at the current visible watermark
13896    /// (P0.5). Prefers the durable receipt HLC for the visible epoch; falls
13897    /// back to the live clock so HLC-stamped rows remain comparable.
13898    pub fn visible_snapshot(&self) -> Snapshot {
13899        self.snapshot_for_epoch(self.epoch.visible())
13900    }
13901
13902    /// Build an HLC-pinned snapshot at a specific commit epoch (P0.5).
13903    ///
13904    /// Prefer the durable ledger stamp for `epoch`, then the live HLC clock,
13905    /// then [`HlcTimestamp::MAX`] so HLC-stamped versions remain readable.
13906    pub fn snapshot_for_epoch(&self, epoch: Epoch) -> Snapshot {
13907        let commit_ts = self
13908            .commit_ts_for_epoch(epoch)
13909            .filter(|ts| *ts != mongreldb_types::hlc::HlcTimestamp::ZERO)
13910            .or_else(|| self.hlc.now().ok())
13911            .unwrap_or(mongreldb_types::hlc::HlcTimestamp::MAX);
13912        Snapshot::at_hlc(epoch, commit_ts)
13913    }
13914
13915    /// Register a read snapshot at the current visible epoch + HLC and return
13916    /// it with a guard that retains it for GC until dropped.
13917    pub fn snapshot(&self) -> (Snapshot, SnapshotGuard<'_>) {
13918        let snap = self.visible_snapshot();
13919        let g = self.snapshots.register(snap.epoch);
13920        (snap, g)
13921    }
13922
13923    /// Owned (clonable-handle) variant of [`Self::snapshot`] for cross-thread
13924    /// retention.
13925    pub fn snapshot_owned(&self) -> (Snapshot, OwnedSnapshotGuard) {
13926        let snap = self.visible_snapshot();
13927        let g = self.snapshots.register_owned(snap.epoch);
13928        (snap, g)
13929    }
13930
13931    /// Configure a rolling history window measured in prior commit epochs.
13932    /// The first enable starts at the current epoch because earlier versions
13933    /// may already have been compacted. Increasing the window likewise cannot
13934    /// recreate history that fell outside the previous guarantee.
13935    pub fn set_history_retention_epochs(&self, epochs: u64) -> Result<()> {
13936        let _guard = self.ddl_lock.lock();
13937        let current = self.epoch.visible();
13938        let (old_epochs, old_start) = self.snapshots.history_config();
13939        let earliest_already_guaranteed = if old_epochs == 0 {
13940            current
13941        } else {
13942            Epoch(old_start.0.max(current.0.saturating_sub(old_epochs)))
13943        };
13944        let start = if epochs == 0 {
13945            current
13946        } else {
13947            earliest_already_guaranteed
13948        };
13949        let published = std::cell::Cell::new(false);
13950        let result = write_history_retention(&self.root, epochs, start, || {
13951            self.snapshots.configure_history(epochs, start);
13952            published.set(true);
13953        });
13954        match result {
13955            Err(error) if published.get() => Err(MongrelError::CommitOutcomeUnknown {
13956                epoch: current.0,
13957                message: format!("history-retention publication was not durable: {error}"),
13958            }),
13959            result => result,
13960        }
13961    }
13962
13963    pub fn history_retention_epochs(&self) -> u64 {
13964        self.snapshots.history_config().0
13965    }
13966
13967    pub fn earliest_retained_epoch(&self) -> Epoch {
13968        let current = self.epoch.visible();
13969        self.snapshots.history_floor(current).unwrap_or(current)
13970    }
13971
13972    /// Pin a guaranteed historical epoch for the lifetime of the returned
13973    /// guard. Rejects future epochs and epochs outside the configured window.
13974    /// When a durable HLC receipt exists for `epoch`, the snapshot is
13975    /// HLC-authoritative (`at_hlc`); otherwise it falls back to epoch-only.
13976    pub fn snapshot_at_owned(&self, epoch: Epoch) -> Result<(Snapshot, OwnedSnapshotGuard)> {
13977        let current = self.epoch.visible();
13978        if epoch > current {
13979            return Err(MongrelError::InvalidArgument(format!(
13980                "epoch {} is in the future; current epoch is {}",
13981                epoch.0, current.0
13982            )));
13983        }
13984        let earliest = self.earliest_retained_epoch();
13985        if epoch < earliest {
13986            return Err(MongrelError::InvalidArgument(format!(
13987                "epoch {} is no longer retained; earliest available epoch is {}",
13988                epoch.0, earliest.0
13989            )));
13990        }
13991        let guard = self.snapshots.register_owned(epoch);
13992        let snap = match self.commit_ts_for_epoch(epoch) {
13993            Some(commit_ts) => Snapshot::at_hlc(epoch, commit_ts),
13994            None => Snapshot::at(epoch),
13995        };
13996        Ok((snap, guard))
13997    }
13998
13999    /// Names of all live tables.
14000    pub fn table_names(&self) -> Vec<String> {
14001        self.catalog
14002            .read()
14003            .tables
14004            .iter()
14005            .filter(|t| matches!(t.state, TableState::Live))
14006            .map(|t| t.name.clone())
14007            .collect()
14008    }
14009
14010    /// Best-effort flush-on-close (§4.4): force-flush every mounted table
14011    /// that has pending writes to a `.sr` sorted run, so WAL segments can be
14012    /// reaped on the next open. Call this as the last action before a
14013    /// short-lived process (CLI, one-shot script) exits. The daemon does not
14014    /// need this — its background auto-compactor handles run management.
14015    pub fn close(&self) -> Result<()> {
14016        for name in self.table_names() {
14017            if let Ok(handle) = self.table(&name) {
14018                if let Err(e) = handle.lock().close() {
14019                    eprintln!("[close] flush failed for {name}: {e}");
14020                }
14021            }
14022        }
14023        Ok(())
14024    }
14025
14026    /// Compact every mounted table: merge all sorted runs into one clean run
14027    /// so query cost stays flat (single-run fast path) instead of growing
14028    /// with run count. Tables with < 2 runs are skipped unless TTL has expired
14029    /// rows to reclaim. Each table
14030    /// is locked individually for its own compaction; snapshot retention is
14031    /// honored by `Table::compact`. Returns `(tables_compacted, tables_skipped)`.
14032    pub fn compact(&self) -> Result<(usize, usize)> {
14033        self.require(&crate::auth::Permission::Ddl)?;
14034        // S1A-004: admit the compaction pass as one core operation.
14035        let _operation = self.admit_operation()?;
14036        let mut compacted = 0;
14037        let mut skipped = 0;
14038        for name in self.table_names() {
14039            let Ok(handle) = self.table(&name) else {
14040                continue;
14041            };
14042            {
14043                let mut t = handle.lock();
14044                let before = t.run_count();
14045                if before < 2 && !t.should_compact() {
14046                    skipped += 1;
14047                    continue;
14048                }
14049                match t.compact() {
14050                    Ok(()) => {
14051                        let after = t.run_count();
14052                        compacted += 1;
14053                        eprintln!("[compact] {name}: {before} -> {after} runs");
14054                    }
14055                    Err(e) => {
14056                        eprintln!("[compact] {name}: compaction failed: {e}");
14057                        skipped += 1;
14058                    }
14059                }
14060            }
14061        }
14062        Ok((compacted, skipped))
14063    }
14064
14065    /// Compact a single table by name. Returns `Ok(true)` if it was
14066    /// compacted, `Ok(false)` if skipped (< 2 runs).
14067    pub fn compact_table(&self, name: &str) -> Result<bool> {
14068        self.require(&crate::auth::Permission::Ddl)?;
14069        let handle = self.table(name)?;
14070        let mut t = handle.lock();
14071        let before = t.run_count();
14072        if before < 2 {
14073            return Ok(false);
14074        }
14075        t.compact()?;
14076        Ok(t.run_count() < before)
14077    }
14078
14079    /// Look up a live table by name.
14080    pub fn table(&self, name: &str) -> Result<TableHandle> {
14081        self.ensure_owner_process()?;
14082        let cat = self.catalog.read();
14083        let entry = cat
14084            .live(name)
14085            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?;
14086        let id = entry.table_id;
14087        drop(cat);
14088        self.tables
14089            .read()
14090            .get(&id)
14091            .cloned()
14092            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not mounted")))
14093    }
14094
14095    /// Rebuild HOT + secondary indexes for one live table from durable runs,
14096    /// the mutable run, and the memtable. Use after an index desync (rows
14097    /// visible on fullscan but missing from PK / equality lookups).
14098    pub fn rebuild_indexes(&self, table: &str) -> Result<()> {
14099        let handle = self.table(table)?;
14100        let mut guard = handle.lock();
14101        guard.rebuild_indexes()
14102    }
14103
14104    /// Rebuild indexes for every live mounted table. Returns the number of
14105    /// tables rebuilt.
14106    pub fn rebuild_all_indexes(&self) -> Result<usize> {
14107        let handles: Vec<_> = self.tables.read().values().cloned().collect();
14108        let mut n = 0usize;
14109        for handle in handles {
14110            handle.lock().rebuild_indexes()?;
14111            n += 1;
14112        }
14113        Ok(n)
14114    }
14115
14116    /// Whether any mounted table has wall-clock TTL retention. SQL sessions
14117    /// use this to avoid epoch-keyed result caches that can outlive a cutoff.
14118    pub fn has_ttl_tables(&self) -> bool {
14119        self.tables
14120            .read()
14121            .values()
14122            .any(|table| table.lock().ttl().is_some())
14123    }
14124
14125    /// Resolve a live table id → mounted handle (used by the constraint
14126    /// validation pass and other id-qualified internal paths).
14127    pub(crate) fn table_by_id(&self, id: u64) -> Result<TableHandle> {
14128        self.tables
14129            .read()
14130            .get(&id)
14131            .cloned()
14132            .ok_or_else(|| MongrelError::NotFound(format!("table id {id} not mounted")))
14133    }
14134
14135    /// S1B-003: whether staging `cells` on `table_id` would ALLOCATE an
14136    /// auto-increment value — the auto-inc column absent or `Null`, mirroring
14137    /// `Table::fill_auto_inc`. The sequence barrier is held only for actual
14138    /// allocation; explicit values merely advance the counter (an
14139    /// order-insensitive `max` under the table lock) and must not serialize.
14140    pub(crate) fn table_auto_inc_would_allocate(
14141        &self,
14142        table_id: u64,
14143        cells: &[(u16, Value)],
14144    ) -> bool {
14145        let catalog = self.catalog.read();
14146        let Some(entry) = catalog
14147            .tables
14148            .iter()
14149            .find(|entry| entry.table_id == table_id)
14150        else {
14151            return false;
14152        };
14153        let Some(column) = entry.schema.auto_increment_column() else {
14154            return false;
14155        };
14156        matches!(
14157            cells.iter().find(|(id, _)| *id == column.id),
14158            None | Some((_, Value::Null))
14159        )
14160    }
14161
14162    /// Create a new table. The DDL is first logged to the shared WAL
14163    /// (`Op::Ddl(CreateTable)` + `TxnCommit`) and group-synced so it is durable
14164    /// BEFORE the in-memory catalog and table map are mutated; the catalog
14165    /// checkpoint is rewritten afterwards (spec §15, review fix #16). A reopen
14166    /// that sees a stale catalog still recovers the table by replaying the Ddl.
14167    pub fn create_table(&self, name: &str, schema: Schema) -> Result<u64> {
14168        if name.starts_with(CTAS_BUILD_TABLE_PREFIX) {
14169            return Err(MongrelError::InvalidArgument(format!(
14170                "table names beginning with {CTAS_BUILD_TABLE_PREFIX:?} are reserved"
14171            )));
14172        }
14173        self.create_table_with_state(name, schema, TableState::Live)
14174    }
14175
14176    /// Create a durable but non-queryable CTAS build table.
14177    #[doc(hidden)]
14178    pub fn create_building_table(
14179        &self,
14180        build_name: &str,
14181        intended_name: &str,
14182        query_id: &str,
14183        schema: Schema,
14184    ) -> Result<u64> {
14185        if !build_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
14186            || intended_name.is_empty()
14187            || intended_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
14188            || query_id.is_empty()
14189        {
14190            return Err(MongrelError::InvalidArgument(
14191                "invalid CTAS building-table identity".into(),
14192            ));
14193        }
14194        self.create_table_with_state(
14195            build_name,
14196            schema,
14197            TableState::Building {
14198                intended_name: intended_name.to_string(),
14199                query_id: query_id.to_string(),
14200                created_at_unix_nanos: current_unix_nanos(),
14201                replaces_table_id: None,
14202            },
14203        )
14204    }
14205
14206    /// Create a hidden schema-rebuild table while the intended target remains
14207    /// live. Publication later validates that the same target is still live.
14208    #[doc(hidden)]
14209    pub fn create_rebuilding_table(
14210        &self,
14211        build_name: &str,
14212        intended_name: &str,
14213        query_id: &str,
14214        schema: Schema,
14215    ) -> Result<u64> {
14216        if !build_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
14217            || intended_name.is_empty()
14218            || intended_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
14219            || query_id.is_empty()
14220        {
14221            return Err(MongrelError::InvalidArgument(
14222                "invalid rebuilding-table identity".into(),
14223            ));
14224        }
14225        let replaces_table_id = self
14226            .catalog
14227            .read()
14228            .live(intended_name)
14229            .ok_or_else(|| MongrelError::NotFound(format!("table {intended_name:?} not found")))?
14230            .table_id;
14231        self.create_table_with_state(
14232            build_name,
14233            schema,
14234            TableState::Building {
14235                intended_name: intended_name.to_string(),
14236                query_id: query_id.to_string(),
14237                created_at_unix_nanos: current_unix_nanos(),
14238                replaces_table_id: Some(replaces_table_id),
14239            },
14240        )
14241    }
14242
14243    fn create_table_with_state(
14244        &self,
14245        name: &str,
14246        schema: Schema,
14247        state: TableState,
14248    ) -> Result<u64> {
14249        use crate::wal::DdlOp;
14250        use std::sync::atomic::Ordering;
14251
14252        self.require(&crate::auth::Permission::Ddl)?;
14253        if self.poisoned.load(Ordering::Relaxed) {
14254            return Err(MongrelError::Other(
14255                "database poisoned by fsync error".into(),
14256            ));
14257        }
14258        // S1A-004: admit the DDL as one core operation.
14259        let _operation = self.admit_operation()?;
14260        let _schema_barrier = self.acquire_schema_barrier_exclusive()?;
14261        let _g = self.ddl_lock.lock();
14262        let _security_write = self.security_write()?;
14263        self.require(&crate::auth::Permission::Ddl)?;
14264        {
14265            let cat = self.catalog.read();
14266            match &state {
14267                TableState::Live => {
14268                    if cat.live(name).is_some() || cat.building_for(name).is_some() {
14269                        return Err(MongrelError::InvalidArgument(format!(
14270                            "table {name:?} already exists or is being built"
14271                        )));
14272                    }
14273                }
14274                TableState::Building {
14275                    intended_name,
14276                    replaces_table_id,
14277                    ..
14278                } => {
14279                    let target_matches = match replaces_table_id {
14280                        Some(table_id) => cat
14281                            .live(intended_name)
14282                            .is_some_and(|entry| entry.table_id == *table_id),
14283                        None => cat.live(intended_name).is_none(),
14284                    };
14285                    if !target_matches || cat.building_for(intended_name).is_some() {
14286                        return Err(MongrelError::InvalidArgument(format!(
14287                            "table {intended_name:?} changed or is already being built"
14288                        )));
14289                    }
14290                    if cat.building(name).is_some() {
14291                        return Err(MongrelError::InvalidArgument(format!(
14292                            "building table {name:?} already exists"
14293                        )));
14294                    }
14295                }
14296                TableState::Dropped { .. } => {
14297                    return Err(MongrelError::InvalidArgument(
14298                        "cannot create a dropped table".into(),
14299                    ));
14300                }
14301            }
14302        }
14303
14304        // Allocate id + epoch + txn id under the commit lock so the DDL commit
14305        // is serialized with data commits (in-order publish). Live creates
14306        // draw their table id from the routed catalog command below (S1F-001);
14307        // CTAS building-table creates keep the legacy direct allocation.
14308        let commit_lock = Arc::clone(&self.commit_lock);
14309        let _c = commit_lock.lock();
14310        let live_create = matches!(state, TableState::Live);
14311        let legacy_table_id = if live_create {
14312            None
14313        } else {
14314            let mut cat = self.catalog.write();
14315            let id = cat.next_table_id;
14316            cat.next_table_id = id
14317                .checked_add(1)
14318                .ok_or_else(|| MongrelError::InvalidArgument("table id space exhausted".into()))?;
14319            Some(id)
14320        };
14321        let epoch = self.epoch.bump_assigned();
14322        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
14323        let txn_id = self.alloc_txn_id()?;
14324
14325        let mut schema = schema;
14326        if let Some(table_id) = legacy_table_id {
14327            // Stamp the schema_id with the unique table_id so every table in
14328            // the database has a distinct schema_id (caller-provided values
14329            // are ignored to prevent collisions).
14330            schema.schema_id = table_id;
14331            // Defense in depth: reject an invalid schema BEFORE any durable
14332            // side-effect. `Table::create_in` re-validates, but by then the
14333            // DDL has already been appended to the shared WAL; a failing
14334            // create_in would leave a dangling entry that `recover_ddl_from_wal`
14335            // replays without re-validating, corrupting the catalog on reopen.
14336            // Validating here keeps the WAL free of schemas that can never be
14337            // opened.
14338            schema.validate_auto_increment()?;
14339            schema.validate_defaults()?;
14340            schema.validate_ai()?;
14341            for index in &schema.indexes {
14342                index.validate_options()?;
14343            }
14344            for constraint in &schema.constraints.checks {
14345                constraint.expr.validate()?;
14346            }
14347        }
14348
14349        let mut next_catalog = self.catalog.read().clone();
14350        let (table_id, schema) = match legacy_table_id {
14351            Some(table_id) => {
14352                next_catalog.tables.push(CatalogEntry {
14353                    table_id,
14354                    name: name.to_string(),
14355                    schema: schema.clone(),
14356                    state: state.clone(),
14357                    created_epoch: epoch.0,
14358                });
14359                (table_id, schema)
14360            }
14361            None => {
14362                // S1F-001: the mutation is a versioned catalog command —
14363                // schema validation (the same five validators), table-id
14364                // allocation, and schema_id stamping all resolve inside it.
14365                let command = crate::catalog_cmds::CatalogCommand::CreateTable {
14366                    name: name.to_string(),
14367                    schema,
14368                    created_epoch: epoch.0,
14369                };
14370                let delta = self.apply_catalog_command_to(&mut next_catalog, command)?;
14371                let crate::catalog_cmds::CatalogDelta::TableCreated { entry } = delta else {
14372                    return Err(MongrelError::Other(
14373                        "CreateTable resolved to an unexpected catalog delta".into(),
14374                    ));
14375                };
14376                (entry.table_id, entry.schema)
14377            }
14378        };
14379        next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
14380
14381        // Build the complete mounted table before its DDL can become durable.
14382        // Any failure removes the unpublished directory and abandons the epoch.
14383        let table_relative = Path::new(TABLES_DIR).join(table_id.to_string());
14384        let canonical_tdir = self.root.join(&table_relative);
14385        let table_root = Arc::new(
14386            self.durable_root
14387                .create_directory_all_pinned(&table_relative)?,
14388        );
14389        let tdir = table_root.io_path()?;
14390        let mut pending_table_dir = PendingTableDir::new(canonical_tdir);
14391        let ctx = SharedCtx {
14392            root_guard: Some(table_root),
14393            epoch: Arc::clone(&self.epoch),
14394            page_cache: Arc::clone(&self.page_cache),
14395            decoded_cache: Arc::clone(&self.decoded_cache),
14396            snapshots: Arc::clone(&self.snapshots),
14397            kek: self.kek.clone(),
14398            commit_lock: Arc::clone(&self.commit_lock),
14399            shared: Some(crate::engine::SharedWalCtx {
14400                wal: Arc::clone(&self.shared_wal),
14401                group: Arc::clone(&self.group),
14402                poisoned: Arc::clone(&self.poisoned),
14403                txn_ids: Arc::clone(&self.next_txn_id),
14404                change_wake: self.change_wake.clone(),
14405                lifecycle: Arc::clone(&self.lifecycle),
14406                hlc: Arc::clone(&self.hlc),
14407            }),
14408            table_name: Some(name.to_string()),
14409            auth: self.table_auth_checker(),
14410            read_only: self.read_only,
14411        };
14412        let table = Table::create_in(&tdir, schema.clone(), table_id, ctx)?;
14413
14414        // 1. Log the DDL + commit marker to the shared WAL, then make it durable
14415        //    via the group-commit coordinator (no fsync under the WAL lock — P3.2).
14416        let schema_json = DdlOp::encode_schema(&schema)?;
14417        let ddl = match &state {
14418            TableState::Live => DdlOp::CreateTable {
14419                table_id,
14420                name: name.to_string(),
14421                schema_json,
14422            },
14423            TableState::Building {
14424                intended_name,
14425                query_id,
14426                created_at_unix_nanos,
14427                replaces_table_id,
14428            } => match replaces_table_id {
14429                Some(replaces_table_id) => DdlOp::CreateRebuildingTable {
14430                    table_id,
14431                    build_name: name.to_string(),
14432                    intended_name: intended_name.clone(),
14433                    query_id: query_id.clone(),
14434                    created_at_unix_nanos: *created_at_unix_nanos,
14435                    replaces_table_id: *replaces_table_id,
14436                    schema_json,
14437                },
14438                None => DdlOp::CreateBuildingTable {
14439                    table_id,
14440                    build_name: name.to_string(),
14441                    intended_name: intended_name.clone(),
14442                    query_id: query_id.clone(),
14443                    created_at_unix_nanos: *created_at_unix_nanos,
14444                    schema_json,
14445                },
14446            },
14447            TableState::Dropped { .. } => {
14448                return Err(MongrelError::InvalidArgument(
14449                    "cannot create a table in dropped state".into(),
14450                ));
14451            }
14452        };
14453        let commit_seq = {
14454            let mut wal = self.shared_wal.lock();
14455            let append: Result<u64> = (|| {
14456                wal.append(txn_id, table_id, crate::wal::Op::Ddl(ddl))?;
14457                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
14458                wal.append_commit(txn_id, epoch, &[])
14459            })();
14460            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
14461        };
14462        let receipt = self.await_durable_commit(txn_id, commit_seq, epoch)?;
14463        pending_table_dir.disarm();
14464
14465        // Publish the mounted table and catalog in memory even if the catalog
14466        // checkpoint fails after the WAL commit.
14467        self.tables
14468            .write()
14469            .insert(table_id, TableHandle::new(table));
14470        let checkpoint = self.checkpoint_catalog_after_durable(next_catalog);
14471        self.finish_durable_publish(epoch, &mut _epoch_guard, &receipt, checkpoint)?;
14472        Ok(table_id)
14473    }
14474
14475    /// Logically drop a table, logging the DDL through the shared WAL first.
14476    pub fn drop_table(&self, name: &str) -> Result<()> {
14477        self.drop_table_with_epoch(name).map(|_| ())
14478    }
14479
14480    /// Logically drop a table and return the exact publication epoch.
14481    pub fn drop_table_with_epoch(&self, name: &str) -> Result<Epoch> {
14482        self.drop_table_with_state(name, false, None)
14483    }
14484
14485    pub fn drop_table_with_epoch_controlled<F>(
14486        &self,
14487        name: &str,
14488        mut before_commit: F,
14489    ) -> Result<Epoch>
14490    where
14491        F: FnMut() -> Result<()>,
14492    {
14493        self.drop_table_with_state(name, false, Some(&mut before_commit))
14494    }
14495
14496    /// Discard an unpublished CTAS build.
14497    #[doc(hidden)]
14498    pub fn discard_building_table(&self, name: &str) -> Result<()> {
14499        if !name.starts_with(CTAS_BUILD_TABLE_PREFIX) {
14500            return Err(MongrelError::InvalidArgument(
14501                "not a CTAS building table".into(),
14502            ));
14503        }
14504        self.drop_table_with_state(name, true, None).map(|_| ())
14505    }
14506
14507    fn drop_table_with_state(
14508        &self,
14509        name: &str,
14510        building: bool,
14511        before_commit: Option<&mut dyn FnMut() -> Result<()>>,
14512    ) -> Result<Epoch> {
14513        use crate::wal::DdlOp;
14514        use std::sync::atomic::Ordering;
14515
14516        self.require(&crate::auth::Permission::Ddl)?;
14517        if self.poisoned.load(Ordering::Relaxed) {
14518            return Err(MongrelError::Other(
14519                "database poisoned by fsync error".into(),
14520            ));
14521        }
14522        // S1A-004: admit the DDL as one core operation.
14523        let _operation = self.admit_operation()?;
14524        let _schema_barrier = self.acquire_schema_barrier_exclusive()?;
14525        let _g = self.ddl_lock.lock();
14526        let _security_write = self.security_write()?;
14527        self.require(&crate::auth::Permission::Ddl)?;
14528        let table_id = {
14529            let cat = self.catalog.read();
14530            if building {
14531                cat.building(name)
14532            } else {
14533                cat.live(name)
14534            }
14535            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?
14536            .table_id
14537        };
14538
14539        let commit_lock = Arc::clone(&self.commit_lock);
14540        let _c = commit_lock.lock();
14541        let epoch = self.epoch.bump_assigned();
14542        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
14543        let txn_id = self.alloc_txn_id()?;
14544        let mut next_catalog = self.catalog.read().clone();
14545        if building {
14546            // CTAS building-table discards stay on the legacy mutation: the
14547            // command model's `DropTable` resolves live tables only.
14548            let entry = next_catalog
14549                .tables
14550                .iter_mut()
14551                .find(|t| t.table_id == table_id)
14552                .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?;
14553            entry.state = TableState::Dropped { at_epoch: epoch.0 };
14554            next_catalog.triggers.retain(|trigger| {
14555                !matches!(
14556                    &trigger.trigger.target,
14557                    TriggerTarget::Table(target) if target == name
14558                )
14559            });
14560            next_catalog
14561                .materialized_views
14562                .retain(|definition| definition.name != name);
14563            next_catalog
14564                .security
14565                .rls_tables
14566                .retain(|table| table != name);
14567            next_catalog
14568                .security
14569                .policies
14570                .retain(|policy| policy.table != name);
14571            next_catalog
14572                .security
14573                .masks
14574                .retain(|mask| mask.table != name);
14575            for role in &mut next_catalog.roles {
14576                role.permissions
14577                    .retain(|permission| permission_table(permission) != Some(name));
14578            }
14579            advance_security_version(&mut next_catalog)?;
14580        } else {
14581            // S1F-001: a plain live-table drop is a versioned catalog command
14582            // (the delta mirrors the legacy mutation above, cascading
14583            // triggers, materialized views, and table-scoped security state).
14584            let command = crate::catalog_cmds::CatalogCommand::DropTable {
14585                name: name.to_string(),
14586                at_epoch: epoch.0,
14587            };
14588            self.apply_catalog_command_to(&mut next_catalog, command)?;
14589        }
14590        next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
14591        let commit_seq = {
14592            let mut wal = self.shared_wal.lock();
14593            if let Some(before_commit) = before_commit {
14594                before_commit()?;
14595            }
14596            let append: Result<u64> = (|| {
14597                wal.append(
14598                    txn_id,
14599                    table_id,
14600                    crate::wal::Op::Ddl(DdlOp::DropTable { table_id }),
14601                )?;
14602                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
14603                wal.append_commit(txn_id, epoch, &[])
14604            })();
14605            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
14606        };
14607        let receipt = self.await_durable_commit(txn_id, commit_seq, epoch)?;
14608
14609        let checkpoint = self.checkpoint_catalog_after_durable(next_catalog);
14610        self.tables.write().remove(&table_id);
14611        self.finish_durable_publish(epoch, &mut _epoch_guard, &receipt, checkpoint)?;
14612        Ok(epoch)
14613    }
14614
14615    /// Rename a live table. `name` must exist and `new_name` must not collide
14616    /// with any live table; both checks run under `ddl_lock` so they are atomic
14617    /// with the rename and with concurrent `create_table` existence checks (no
14618    /// TOCTOU window). A no-op rename (`name == new_name`) succeeds without
14619    /// side-effects. The rename is logged to the shared WAL as
14620    /// `DdlOp::RenameTable` and recovered on reopen; the `table_id`, schema,
14621    /// and on-disk layout are unchanged (the table is keyed by `table_id`, so
14622    /// the in-memory object does not move — only the catalog name changes).
14623    pub fn rename_table(&self, name: &str, new_name: &str) -> Result<()> {
14624        self.rename_table_with_epoch(name, new_name).map(|_| ())
14625    }
14626
14627    /// Rename a table and return its exact publication epoch.
14628    pub fn rename_table_with_epoch(&self, name: &str, new_name: &str) -> Result<Epoch> {
14629        self.rename_table_with_epoch_inner(name, new_name, None)
14630    }
14631
14632    pub fn rename_table_with_epoch_controlled<F>(
14633        &self,
14634        name: &str,
14635        new_name: &str,
14636        mut before_commit: F,
14637    ) -> Result<Epoch>
14638    where
14639        F: FnMut() -> Result<()>,
14640    {
14641        self.rename_table_with_epoch_inner(name, new_name, Some(&mut before_commit))
14642    }
14643
14644    fn rename_table_with_epoch_inner(
14645        &self,
14646        name: &str,
14647        new_name: &str,
14648        before_commit: Option<&mut dyn FnMut() -> Result<()>>,
14649    ) -> Result<Epoch> {
14650        if name.starts_with(CTAS_BUILD_TABLE_PREFIX)
14651            || new_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
14652        {
14653            return Err(MongrelError::InvalidArgument(
14654                "the CTAS building-table namespace is reserved".into(),
14655            ));
14656        }
14657        self.rename_table_with_state(name, new_name, false, None, before_commit)
14658    }
14659
14660    /// Atomically publish a hidden CTAS build under its intended live name.
14661    #[doc(hidden)]
14662    pub fn publish_building_table(&self, build_name: &str, new_name: &str) -> Result<Epoch> {
14663        self.publish_building_table_inner(build_name, new_name, None)
14664    }
14665
14666    #[doc(hidden)]
14667    pub fn publish_building_table_controlled<F>(
14668        &self,
14669        build_name: &str,
14670        new_name: &str,
14671        mut before_commit: F,
14672    ) -> Result<Epoch>
14673    where
14674        F: FnMut() -> Result<()>,
14675    {
14676        self.publish_building_table_inner(build_name, new_name, Some(&mut before_commit))
14677    }
14678
14679    fn publish_building_table_inner(
14680        &self,
14681        build_name: &str,
14682        new_name: &str,
14683        before_commit: Option<&mut dyn FnMut() -> Result<()>>,
14684    ) -> Result<Epoch> {
14685        if !build_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
14686            || new_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
14687        {
14688            return Err(MongrelError::InvalidArgument(
14689                "invalid CTAS publish identity".into(),
14690            ));
14691        }
14692        self.rename_table_with_state(build_name, new_name, true, None, before_commit)
14693    }
14694
14695    /// Atomically publish a hidden build and its materialized-view definition.
14696    #[doc(hidden)]
14697    pub fn publish_materialized_building_table(
14698        &self,
14699        build_name: &str,
14700        new_name: &str,
14701        definition: crate::catalog::MaterializedViewEntry,
14702    ) -> Result<Epoch> {
14703        self.publish_materialized_building_table_inner(build_name, new_name, definition, None)
14704    }
14705
14706    #[doc(hidden)]
14707    pub fn publish_materialized_building_table_controlled<F>(
14708        &self,
14709        build_name: &str,
14710        new_name: &str,
14711        definition: crate::catalog::MaterializedViewEntry,
14712        mut before_commit: F,
14713    ) -> Result<Epoch>
14714    where
14715        F: FnMut() -> Result<()>,
14716    {
14717        self.publish_materialized_building_table_inner(
14718            build_name,
14719            new_name,
14720            definition,
14721            Some(&mut before_commit),
14722        )
14723    }
14724
14725    fn publish_materialized_building_table_inner(
14726        &self,
14727        build_name: &str,
14728        new_name: &str,
14729        definition: crate::catalog::MaterializedViewEntry,
14730        before_commit: Option<&mut dyn FnMut() -> Result<()>>,
14731    ) -> Result<Epoch> {
14732        if definition.name != new_name || definition.query.trim().is_empty() {
14733            return Err(MongrelError::InvalidArgument(
14734                "invalid materialized-view publication".into(),
14735            ));
14736        }
14737        self.rename_table_with_state(build_name, new_name, true, Some(definition), before_commit)
14738    }
14739
14740    /// Atomically replace a still-live table with its completed hidden rebuild.
14741    #[doc(hidden)]
14742    pub fn publish_rebuilding_table(&self, build_name: &str, new_name: &str) -> Result<Epoch> {
14743        self.publish_rebuilding_table_inner(build_name, new_name, None, None)
14744    }
14745
14746    #[doc(hidden)]
14747    pub fn publish_rebuilding_table_controlled<F>(
14748        &self,
14749        build_name: &str,
14750        new_name: &str,
14751        mut before_commit: F,
14752    ) -> Result<Epoch>
14753    where
14754        F: FnMut() -> Result<()>,
14755    {
14756        self.publish_rebuilding_table_inner(build_name, new_name, None, Some(&mut before_commit))
14757    }
14758
14759    /// Atomically replace a live materialized-view table and its definition.
14760    #[doc(hidden)]
14761    pub fn publish_materialized_rebuilding_table_controlled<F>(
14762        &self,
14763        build_name: &str,
14764        new_name: &str,
14765        definition: crate::catalog::MaterializedViewEntry,
14766        mut before_commit: F,
14767    ) -> Result<Epoch>
14768    where
14769        F: FnMut() -> Result<()>,
14770    {
14771        self.publish_rebuilding_table_inner(
14772            build_name,
14773            new_name,
14774            Some(definition),
14775            Some(&mut before_commit),
14776        )
14777    }
14778
14779    fn publish_rebuilding_table_inner(
14780        &self,
14781        build_name: &str,
14782        new_name: &str,
14783        mut materialized_view: Option<crate::catalog::MaterializedViewEntry>,
14784        before_commit: Option<&mut dyn FnMut() -> Result<()>>,
14785    ) -> Result<Epoch> {
14786        use crate::wal::DdlOp;
14787
14788        if !build_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
14789            || new_name.is_empty()
14790            || new_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
14791        {
14792            return Err(MongrelError::InvalidArgument(
14793                "invalid rebuilding-table publish identity".into(),
14794            ));
14795        }
14796        if materialized_view.as_ref().is_some_and(|definition| {
14797            definition.name != new_name || definition.query.trim().is_empty()
14798        }) {
14799            return Err(MongrelError::InvalidArgument(
14800                "invalid materialized-view replacement".into(),
14801            ));
14802        }
14803        self.require(&crate::auth::Permission::Ddl)?;
14804        if self.poisoned.load(Ordering::Relaxed) {
14805            return Err(MongrelError::Other(
14806                "database poisoned by fsync error".into(),
14807            ));
14808        }
14809        // S1A-004: admit the DDL as one core operation.
14810        let _operation = self.admit_operation()?;
14811        let _schema_barrier = self.acquire_schema_barrier_exclusive()?;
14812        let _ddl = self.ddl_lock.lock();
14813        let _security_write = self.security_write()?;
14814        let (table_id, replaced_table_id) = {
14815            let catalog = self.catalog.read();
14816            let build = catalog.building(build_name).ok_or_else(|| {
14817                MongrelError::NotFound(format!("building table {build_name:?} not found"))
14818            })?;
14819            let replaced_table_id = match &build.state {
14820                TableState::Building {
14821                    intended_name,
14822                    replaces_table_id: Some(replaced_table_id),
14823                    ..
14824                } if intended_name == new_name => *replaced_table_id,
14825                _ => {
14826                    return Err(MongrelError::InvalidArgument(format!(
14827                        "building table {build_name:?} is not a replacement for {new_name:?}"
14828                    )))
14829                }
14830            };
14831            if catalog
14832                .live(new_name)
14833                .is_none_or(|entry| entry.table_id != replaced_table_id)
14834            {
14835                return Err(MongrelError::Conflict(format!(
14836                    "table {new_name:?} changed while its replacement was built"
14837                )));
14838            }
14839            (build.table_id, replaced_table_id)
14840        };
14841
14842        let _commit = self.commit_lock.lock();
14843        let epoch = self.epoch.assigned().next();
14844        let txn_id = self.alloc_txn_id()?;
14845        let mut next_catalog = self.catalog.read().clone();
14846        apply_rebuilding_publish(
14847            &mut next_catalog,
14848            table_id,
14849            replaced_table_id,
14850            new_name,
14851            epoch.0,
14852        )?;
14853        if let Some(definition) = materialized_view.as_mut() {
14854            definition.last_refresh_epoch = epoch.0;
14855        }
14856        let materialized_view_json = materialized_view
14857            .as_ref()
14858            .map(DdlOp::encode_materialized_view)
14859            .transpose()?;
14860        if let Some(definition) = materialized_view {
14861            if let Some(existing) = next_catalog
14862                .materialized_views
14863                .iter_mut()
14864                .find(|existing| existing.name == definition.name)
14865            {
14866                *existing = definition;
14867            } else {
14868                next_catalog.materialized_views.push(definition);
14869            }
14870        }
14871        next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
14872        if let Some(before_commit) = before_commit {
14873            before_commit()?;
14874        }
14875        let assigned_epoch = self.epoch.bump_assigned();
14876        let mut epoch_guard = EpochGuard::new(self.epoch.as_ref(), assigned_epoch);
14877        if assigned_epoch != epoch {
14878            return Err(MongrelError::Conflict(
14879                "commit epoch changed while sequencer lock was held".into(),
14880            ));
14881        }
14882        let commit_seq = {
14883            let mut wal = self.shared_wal.lock();
14884            let append: Result<u64> = (|| {
14885                wal.append(
14886                    txn_id,
14887                    table_id,
14888                    crate::wal::Op::Ddl(DdlOp::ReplaceBuildingTable {
14889                        table_id,
14890                        replaced_table_id,
14891                        new_name: new_name.to_string(),
14892                    }),
14893                )?;
14894                if let Some(definition_json) = materialized_view_json {
14895                    wal.append(
14896                        txn_id,
14897                        table_id,
14898                        crate::wal::Op::Ddl(DdlOp::SetMaterializedView {
14899                            name: new_name.to_string(),
14900                            definition_json,
14901                        }),
14902                    )?;
14903                }
14904                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
14905                wal.append_commit(txn_id, epoch, &[])
14906            })();
14907            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
14908        };
14909        let receipt = self.await_durable_commit(txn_id, commit_seq, epoch)?;
14910
14911        let checkpoint = self.checkpoint_catalog_after_durable(next_catalog);
14912        self.tables.write().remove(&replaced_table_id);
14913        if let Some(table) = self.tables.read().get(&table_id) {
14914            table.lock().set_catalog_name(new_name.to_string());
14915        }
14916        self.finish_durable_publish(epoch, &mut epoch_guard, &receipt, checkpoint)?;
14917        Ok(epoch)
14918    }
14919
14920    fn rename_table_with_state(
14921        &self,
14922        name: &str,
14923        new_name: &str,
14924        building: bool,
14925        mut materialized_view: Option<crate::catalog::MaterializedViewEntry>,
14926        before_commit: Option<&mut dyn FnMut() -> Result<()>>,
14927    ) -> Result<Epoch> {
14928        use crate::wal::DdlOp;
14929        use std::sync::atomic::Ordering;
14930
14931        self.require(&crate::auth::Permission::Ddl)?;
14932        if self.poisoned.load(Ordering::Relaxed) {
14933            return Err(MongrelError::Other(
14934                "database poisoned by fsync error".into(),
14935            ));
14936        }
14937
14938        // A no-op rename short-circuits before any locking, so it can never
14939        // trip the "target already exists" check (the source *is* that name).
14940        if name == new_name {
14941            return Ok(self.visible_epoch());
14942        }
14943        if new_name.is_empty() {
14944            return Err(MongrelError::InvalidArgument(
14945                "rename_table: new name must not be empty".into(),
14946            ));
14947        }
14948        // S1A-004: admit the DDL as one core operation.
14949        let _operation = self.admit_operation()?;
14950        let _schema_barrier = self.acquire_schema_barrier_exclusive()?;
14951        let _g = self.ddl_lock.lock();
14952        let _security_write = self.security_write()?;
14953        self.require(&crate::auth::Permission::Ddl)?;
14954        let table_id = {
14955            let cat = self.catalog.read();
14956            let src = if building {
14957                cat.building(name)
14958            } else {
14959                cat.live(name)
14960            }
14961            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?;
14962            if building
14963                && !matches!(
14964                    &src.state,
14965                    TableState::Building { intended_name, .. } if intended_name == new_name
14966                )
14967            {
14968                return Err(MongrelError::InvalidArgument(format!(
14969                    "building table {name:?} is not reserved for {new_name:?}"
14970                )));
14971            }
14972            // Target must be free. Checked under ddl_lock, which every other
14973            // DDL (create/rename/drop) also holds, so a concurrent operation
14974            // cannot claim `new_name` between this check and the catalog write.
14975            if cat.live(new_name).is_some() || (!building && cat.building_for(new_name).is_some()) {
14976                return Err(MongrelError::InvalidArgument(format!(
14977                    "rename_table: a table named {new_name:?} already exists"
14978                )));
14979            }
14980            src.table_id
14981        };
14982
14983        let commit_lock = Arc::clone(&self.commit_lock);
14984        let _c = commit_lock.lock();
14985        let epoch = self.epoch.bump_assigned();
14986        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
14987        let txn_id = self.alloc_txn_id()?;
14988        if let Some(definition) = materialized_view.as_mut() {
14989            definition.last_refresh_epoch = epoch.0;
14990        }
14991        let materialized_view_json = materialized_view
14992            .as_ref()
14993            .map(DdlOp::encode_materialized_view)
14994            .transpose()?;
14995        let mut next_catalog = self.catalog.read().clone();
14996        if building {
14997            // CTAS building-table publishes stay on the legacy mutation: the
14998            // command model's `RenameTable` resolves live tables only.
14999            let entry = next_catalog
15000                .tables
15001                .iter_mut()
15002                .find(|t| t.table_id == table_id)
15003                .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?;
15004            entry.name = new_name.to_string();
15005            entry.state = TableState::Live;
15006            for trigger in &mut next_catalog.triggers {
15007                if matches!(
15008                    &trigger.trigger.target,
15009                    TriggerTarget::Table(target) if target == name
15010                ) {
15011                    trigger.trigger = trigger.trigger.retarget_table(new_name, epoch.0)?;
15012                }
15013            }
15014            if let Some(definition) = next_catalog
15015                .materialized_views
15016                .iter_mut()
15017                .find(|definition| definition.name == name)
15018            {
15019                definition.name = new_name.to_string();
15020            }
15021            if let Some(definition) = materialized_view.take() {
15022                next_catalog.materialized_views.push(definition);
15023            }
15024            for table in &mut next_catalog.security.rls_tables {
15025                if table == name {
15026                    *table = new_name.to_string();
15027                }
15028            }
15029            for policy in &mut next_catalog.security.policies {
15030                if policy.table == name {
15031                    policy.table = new_name.to_string();
15032                }
15033            }
15034            for mask in &mut next_catalog.security.masks {
15035                if mask.table == name {
15036                    mask.table = new_name.to_string();
15037                }
15038            }
15039            for role in &mut next_catalog.roles {
15040                for permission in &mut role.permissions {
15041                    rename_permission_table(permission, name, new_name);
15042                }
15043            }
15044            advance_security_version(&mut next_catalog)?;
15045        } else {
15046            // S1F-001: a plain live-table rename is a versioned catalog
15047            // command (the delta mirrors the legacy mutation above, including
15048            // trigger retargets and table-scoped security state).
15049            let command = crate::catalog_cmds::CatalogCommand::RenameTable {
15050                name: name.to_string(),
15051                new_name: new_name.to_string(),
15052                at_epoch: epoch.0,
15053            };
15054            self.apply_catalog_command_to(&mut next_catalog, command)?;
15055        }
15056        next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
15057        let ddl = if building {
15058            DdlOp::PublishBuildingTable {
15059                table_id,
15060                new_name: new_name.to_string(),
15061            }
15062        } else {
15063            DdlOp::RenameTable {
15064                table_id,
15065                new_name: new_name.to_string(),
15066            }
15067        };
15068        let commit_seq = {
15069            let mut wal = self.shared_wal.lock();
15070            if let Some(before_commit) = before_commit {
15071                before_commit()?;
15072            }
15073            let append: Result<u64> = (|| {
15074                wal.append(txn_id, table_id, crate::wal::Op::Ddl(ddl))?;
15075                if let Some(definition_json) = materialized_view_json {
15076                    wal.append(
15077                        txn_id,
15078                        table_id,
15079                        crate::wal::Op::Ddl(DdlOp::SetMaterializedView {
15080                            name: new_name.to_string(),
15081                            definition_json,
15082                        }),
15083                    )?;
15084                }
15085                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
15086                wal.append_commit(txn_id, epoch, &[])
15087            })();
15088            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
15089        };
15090        let receipt = self.await_durable_commit(txn_id, commit_seq, epoch)?;
15091
15092        let checkpoint = self.checkpoint_catalog_after_durable(next_catalog);
15093        // The in-memory table object is keyed by table_id, not name, so it does
15094        // not move and live TableHandles remain valid.
15095        if let Some(table) = self.tables.read().get(&table_id) {
15096            table.lock().set_catalog_name(new_name.to_string());
15097        }
15098        self.finish_durable_publish(epoch, &mut _epoch_guard, &receipt, checkpoint)?;
15099        Ok(epoch)
15100    }
15101
15102    /// Add a column through the database catalog and shared WAL.
15103    ///
15104    /// This is the catalog-aware counterpart to [`Table::add_column`]. The
15105    /// mounted table schema, catalog image, and recovery record advance as one
15106    /// durable DDL commit.
15107    pub fn add_column(
15108        &self,
15109        table_name: &str,
15110        name: &str,
15111        ty: TypeId,
15112        flags: ColumnFlags,
15113        default_value: Option<crate::schema::DefaultExpr>,
15114    ) -> Result<ColumnDef> {
15115        self.add_column_with_id(table_name, name, ty, flags, default_value, None)
15116    }
15117
15118    /// Add a catalog-aware column with an optional caller-assigned stable id.
15119    pub fn add_column_with_id(
15120        &self,
15121        table_name: &str,
15122        name: &str,
15123        ty: TypeId,
15124        flags: ColumnFlags,
15125        default_value: Option<crate::schema::DefaultExpr>,
15126        requested_id: Option<u16>,
15127    ) -> Result<ColumnDef> {
15128        use crate::wal::DdlOp;
15129        use std::sync::atomic::Ordering;
15130
15131        self.require(&crate::auth::Permission::Ddl)?;
15132        if self.poisoned.load(Ordering::Relaxed) {
15133            return Err(MongrelError::Other(
15134                "database poisoned by fsync error".into(),
15135            ));
15136        }
15137        let _operation = self.admit_operation()?;
15138        let _schema_barrier = self.acquire_schema_barrier_exclusive()?;
15139        let _ddl = self.ddl_lock.lock();
15140        let _security_write = self.security_write()?;
15141        self.require(&crate::auth::Permission::Ddl)?;
15142        let table_id = {
15143            let catalog = self.catalog.read();
15144            catalog
15145                .live(table_name)
15146                .ok_or_else(|| MongrelError::NotFound(format!("table {table_name:?} not found")))?
15147                .table_id
15148        };
15149        let handle =
15150            self.tables.read().get(&table_id).cloned().ok_or_else(|| {
15151                MongrelError::NotFound(format!("table {table_name:?} not mounted"))
15152            })?;
15153        let durable_epoch = std::cell::Cell::new(None);
15154        let result: Result<ColumnDef> = (|| {
15155            let mut table = handle.lock();
15156            let (column, prepared_schema) =
15157                table.prepare_add_column(name, ty, flags, default_value, requested_id)?;
15158            let command = crate::catalog_cmds::CatalogCommand::AddColumn {
15159                table: table_name.to_string(),
15160                column: column.clone(),
15161            };
15162            crate::catalog_cmds::apply(&self.catalog.read(), &command)?;
15163
15164            let commit_lock = Arc::clone(&self.commit_lock);
15165            let _commit = commit_lock.lock();
15166            let epoch = self.epoch.bump_assigned();
15167            let mut epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
15168            let txn_id = self.alloc_txn_id()?;
15169            let column_json = DdlOp::encode_column(&column)?;
15170            let mut next_catalog = self.catalog.read().clone();
15171            let catalog_entry_index = next_catalog
15172                .tables
15173                .iter()
15174                .position(|entry| entry.table_id == table_id)
15175                .ok_or_else(|| MongrelError::NotFound(format!("table {table_name:?} not found")))?;
15176            self.apply_catalog_command_to(&mut next_catalog, command)?;
15177            next_catalog.tables[catalog_entry_index].schema = prepared_schema.clone();
15178            next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
15179            let commit_seq = {
15180                let mut wal = self.shared_wal.lock();
15181                let append: Result<u64> = (|| {
15182                    wal.append(
15183                        txn_id,
15184                        table_id,
15185                        crate::wal::Op::Ddl(DdlOp::AlterTable {
15186                            table_id,
15187                            column_json,
15188                        }),
15189                    )?;
15190                    append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
15191                    wal.append_commit(txn_id, epoch, &[])
15192                })();
15193                append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
15194            };
15195            let receipt = self.await_durable_commit(txn_id, commit_seq, epoch)?;
15196            durable_epoch.set(Some(epoch));
15197
15198            table.apply_altered_schema_prepared(prepared_schema);
15199            let schema = table.schema().clone();
15200            let table_checkpoint = table.checkpoint_altered_schema();
15201            drop(table);
15202            next_catalog.tables[catalog_entry_index].schema = schema;
15203            let catalog_result =
15204                catalog::write_atomic(&self.root, &next_catalog, self.meta_dek.as_ref());
15205            *self.catalog.write() = next_catalog;
15206            self.publish_committed(&receipt, epoch)?;
15207            epoch_guard.disarm();
15208            if let Err(error) = table_checkpoint.and(catalog_result) {
15209                self.poisoned.store(true, Ordering::Relaxed);
15210                self.lifecycle.poison();
15211                return Err(MongrelError::DurableCommit {
15212                    epoch: epoch.0,
15213                    message: error.to_string(),
15214                });
15215            }
15216            Ok(column)
15217        })();
15218        result.map_err(|error| match (durable_epoch.get(), error) {
15219            (_, error @ MongrelError::DurableCommit { .. }) => error,
15220            (Some(epoch), error) => MongrelError::DurableCommit {
15221                epoch: epoch.0,
15222                message: error.to_string(),
15223            },
15224            (None, error) => error,
15225        })
15226    }
15227
15228    pub fn alter_column(
15229        &self,
15230        table_name: &str,
15231        column_name: &str,
15232        change: AlterColumn,
15233    ) -> Result<ColumnDef> {
15234        self.alter_column_with_epoch(table_name, column_name, change)
15235            .map(|(column, _)| column)
15236    }
15237
15238    pub fn alter_column_with_epoch(
15239        &self,
15240        table_name: &str,
15241        column_name: &str,
15242        change: AlterColumn,
15243    ) -> Result<(ColumnDef, Option<Epoch>)> {
15244        self.alter_column_with_epoch_inner(table_name, column_name, change, None, None, None)
15245    }
15246
15247    /// Cooperatively prepare an ALTER and fence each durable commit separately.
15248    /// `after_commit(Some(epoch))` follows an exact durable outcome;
15249    /// `after_commit(None)` follows an uncertain WAL attempt. It is called once
15250    /// for every successful `before_commit` callback.
15251    pub fn alter_column_with_epoch_controlled<B, A>(
15252        &self,
15253        table_name: &str,
15254        column_name: &str,
15255        change: AlterColumn,
15256        control: &crate::ExecutionControl,
15257        mut before_commit: B,
15258        mut after_commit: A,
15259    ) -> Result<(ColumnDef, Option<Epoch>)>
15260    where
15261        B: FnMut() -> Result<()>,
15262        A: FnMut(Option<Epoch>) -> Result<()>,
15263    {
15264        self.alter_column_with_epoch_inner(
15265            table_name,
15266            column_name,
15267            change,
15268            Some(control),
15269            Some(&mut before_commit),
15270            Some(&mut after_commit),
15271        )
15272    }
15273
15274    #[allow(clippy::too_many_arguments)]
15275    fn alter_column_with_epoch_inner(
15276        &self,
15277        table_name: &str,
15278        column_name: &str,
15279        change: AlterColumn,
15280        control: Option<&crate::ExecutionControl>,
15281        mut before_commit: Option<&mut dyn FnMut() -> Result<()>>,
15282        mut after_commit: Option<&mut dyn FnMut(Option<Epoch>) -> Result<()>>,
15283    ) -> Result<(ColumnDef, Option<Epoch>)> {
15284        use crate::wal::DdlOp;
15285        use std::sync::atomic::Ordering;
15286
15287        self.require(&crate::auth::Permission::Ddl)?;
15288        commit_prepare_checkpoint(control, 0)?;
15289        if self.poisoned.load(Ordering::Relaxed) {
15290            return Err(MongrelError::Other(
15291                "database poisoned by fsync error".into(),
15292            ));
15293        }
15294        // S1A-004: admit the DDL as one core operation.
15295        let _operation = self.admit_operation()?;
15296        let _schema_barrier = self.acquire_schema_barrier_exclusive()?;
15297        let _g = self.ddl_lock.lock();
15298        let table_id = {
15299            let cat = self.catalog.read();
15300            cat.live(table_name)
15301                .ok_or_else(|| MongrelError::NotFound(format!("table {table_name:?} not found")))?
15302                .table_id
15303        };
15304        let handle =
15305            self.tables.read().get(&table_id).cloned().ok_or_else(|| {
15306                MongrelError::NotFound(format!("table {table_name:?} not mounted"))
15307            })?;
15308
15309        // Legitimate online-ALTER slice: when nullable -> NOT NULL has a
15310        // declared default, backfill existing NULL/absent cells as one durable
15311        // transaction before logging the metadata change. A crash between the
15312        // two commits leaves a harmless nullable-but-filled column; retry is
15313        // idempotent because only remaining NULLs are touched.
15314        let backfill = {
15315            let table = handle.lock();
15316            let old = table
15317                .schema()
15318                .column(column_name)
15319                .cloned()
15320                .ok_or_else(|| MongrelError::Schema(format!("unknown column {column_name}")))?;
15321            let next_flags = change.flags.unwrap_or(old.flags);
15322            if old.flags.contains(crate::schema::ColumnFlags::NULLABLE)
15323                && !next_flags.contains(crate::schema::ColumnFlags::NULLABLE)
15324                && old.default_value.is_some()
15325            {
15326                let snapshot = self.snapshot_for_epoch(self.epoch.visible());
15327                let mut updates = Vec::new();
15328                let rows = match control {
15329                    Some(control) => table.visible_rows_controlled(snapshot, control)?,
15330                    None => table.visible_rows(snapshot)?,
15331                };
15332                for (row_index, row) in rows.into_iter().enumerate() {
15333                    commit_prepare_checkpoint(control, row_index)?;
15334                    if row
15335                        .columns
15336                        .get(&old.id)
15337                        .is_some_and(|value| !matches!(value, Value::Null))
15338                    {
15339                        continue;
15340                    }
15341                    let mut cells: Vec<(u16, Value)> = row.columns.into_iter().collect();
15342                    table.apply_defaults(&mut cells)?;
15343                    updates.push((
15344                        table_id,
15345                        crate::txn::Staged::Update {
15346                            row_id: row.row_id,
15347                            new_row: cells,
15348                            changed_columns: vec![old.id],
15349                        },
15350                    ));
15351                }
15352                updates
15353            } else {
15354                Vec::new()
15355            }
15356        };
15357        let durable_epoch = std::cell::Cell::new(None);
15358        let backfill_epoch = if backfill.is_empty() {
15359            None
15360        } else {
15361            let (principal, catalog_bound) = self.transaction_principal_snapshot();
15362            let txn_id = self.alloc_txn_id()?;
15363            let mut entered_fence = false;
15364            let commit_result = match (control, before_commit.as_deref_mut()) {
15365                (Some(control), Some(before_commit)) => self
15366                    .commit_transaction_with_external_states_controlled(
15367                        txn_id,
15368                        self.epoch.visible(),
15369                        backfill,
15370                        Vec::new(),
15371                        Vec::new(),
15372                        principal.clone(),
15373                        catalog_bound,
15374                        None,
15375                        crate::txn::TxnCommitContext::internal(),
15376                        control,
15377                        &mut || {
15378                            before_commit()?;
15379                            entered_fence = true;
15380                            Ok(())
15381                        },
15382                    )
15383                    .map(|(epoch, _)| epoch),
15384                _ => self
15385                    .commit_transaction_with_external_states(
15386                        txn_id,
15387                        self.epoch.visible(),
15388                        backfill,
15389                        Vec::new(),
15390                        Vec::new(),
15391                        principal,
15392                        catalog_bound,
15393                        None,
15394                        crate::txn::TxnCommitContext::internal(),
15395                    )
15396                    .map(|(epoch, _)| epoch),
15397            };
15398            let commit_result = if entered_fence {
15399                finish_controlled_commit_attempt(commit_result, &mut after_commit)
15400            } else {
15401                commit_result
15402            };
15403            match &commit_result {
15404                Ok(epoch) => durable_epoch.set(Some(*epoch)),
15405                Err(MongrelError::DurableCommit { epoch, .. }) => {
15406                    durable_epoch.set(Some(Epoch(*epoch)));
15407                }
15408                Err(_) => {}
15409            }
15410            Some(commit_result?)
15411        };
15412        let result: Result<(ColumnDef, Option<Epoch>)> = (|| {
15413            let _security_write = self.security_write()?;
15414            self.require(&crate::auth::Permission::Ddl)?;
15415            if self
15416                .catalog
15417                .read()
15418                .live(table_name)
15419                .is_none_or(|entry| entry.table_id != table_id)
15420            {
15421                return Err(MongrelError::Conflict(format!(
15422                    "table {table_name:?} changed during ALTER"
15423                )));
15424            }
15425            let mut table = handle.lock();
15426            let (column, prepared_schema) = table.prepare_alter_column(column_name, &change)?;
15427            let renamed_column = (column.name != column_name).then(|| column.name.clone());
15428            let Some(prepared_schema) = prepared_schema else {
15429                return Ok((column, backfill_epoch));
15430            };
15431            // S1F-001: the schema mutation is a versioned catalog command. It
15432            // validates pure against the current catalog before an epoch is
15433            // consumed; the engine's post-apply schema (schema_id bump
15434            // included) is the resolved delta the wrapper publishes.
15435            let command = crate::catalog_cmds::CatalogCommand::AlterColumn {
15436                table: table_name.to_string(),
15437                column: column.clone(),
15438            };
15439            crate::catalog_cmds::apply(&self.catalog.read(), &command)?;
15440
15441            let commit_lock = Arc::clone(&self.commit_lock);
15442            let _c = commit_lock.lock();
15443            let epoch = self.epoch.bump_assigned();
15444            let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
15445            let txn_id = self.alloc_txn_id()?;
15446            let column_json = DdlOp::encode_column(&column)?;
15447            let mut next_catalog = self.catalog.read().clone();
15448            let catalog_entry_index = next_catalog
15449                .tables
15450                .iter()
15451                .position(|entry| entry.table_id == table_id)
15452                .ok_or_else(|| MongrelError::NotFound(format!("table {table_name:?} not found")))?;
15453            if let Some(new_column_name) = &renamed_column {
15454                for (trigger_index, trigger) in next_catalog.triggers.iter_mut().enumerate() {
15455                    commit_prepare_checkpoint(control, trigger_index)?;
15456                    if matches!(
15457                        &trigger.trigger.target,
15458                        TriggerTarget::Table(target) if target == table_name
15459                    ) {
15460                        trigger.trigger = trigger.trigger.renamed_update_column(
15461                            column_name,
15462                            new_column_name.clone(),
15463                            epoch.0,
15464                        )?;
15465                    }
15466                }
15467                for (role_index, role) in next_catalog.roles.iter_mut().enumerate() {
15468                    commit_prepare_checkpoint(control, role_index)?;
15469                    for (permission_index, permission) in role.permissions.iter_mut().enumerate() {
15470                        commit_prepare_checkpoint(control, permission_index)?;
15471                        rename_permission_column(
15472                            permission,
15473                            table_name,
15474                            column_name,
15475                            new_column_name,
15476                        );
15477                    }
15478                }
15479                advance_security_version(&mut next_catalog)?;
15480            }
15481            // Record the versioned command (validating again against the
15482            // candidate), then install the engine-resolved schema image:
15483            // identical to the command's delta when the mounted table and the
15484            // catalog are in sync, and byte-for-byte what the legacy inline
15485            // mutation published either way.
15486            self.apply_catalog_command_to(&mut next_catalog, command)?;
15487            next_catalog.tables[catalog_entry_index].schema = prepared_schema.clone();
15488            next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
15489            commit_prepare_checkpoint(control, 0)?;
15490            let mut entered_fence = false;
15491            if let Some(before_commit) = before_commit.as_deref_mut() {
15492                before_commit()?;
15493                entered_fence = true;
15494            }
15495            let commit_result: Result<Epoch> = (|| {
15496                let commit_seq = {
15497                    let mut wal = self.shared_wal.lock();
15498                    let append: Result<u64> = (|| {
15499                        wal.append(
15500                            txn_id,
15501                            table_id,
15502                            crate::wal::Op::Ddl(DdlOp::AlterTable {
15503                                table_id,
15504                                column_json,
15505                            }),
15506                        )?;
15507                        append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
15508                        wal.append_commit(txn_id, epoch, &[])
15509                    })();
15510                    append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
15511                };
15512                let receipt = self.await_durable_commit(txn_id, commit_seq, epoch)?;
15513                durable_epoch.set(Some(epoch));
15514
15515                table.apply_altered_schema_prepared(prepared_schema);
15516                let schema = table.schema().clone();
15517                let table_checkpoint = table.checkpoint_altered_schema();
15518                drop(table);
15519                next_catalog.tables[catalog_entry_index].schema = schema;
15520                next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
15521                let catalog_result =
15522                    catalog::write_atomic(&self.root, &next_catalog, self.meta_dek.as_ref());
15523                let security_version = next_catalog.security_version;
15524                *self.catalog.write() = next_catalog;
15525                if renamed_column.is_some() {
15526                    self.security_coordinator
15527                        .version
15528                        .store(security_version, Ordering::Release);
15529                }
15530                self.publish_committed(&receipt, epoch)?;
15531                _epoch_guard.disarm();
15532                if let Err(error) = table_checkpoint.and(catalog_result) {
15533                    self.poisoned.store(true, Ordering::Relaxed);
15534                    self.lifecycle.poison();
15535                    return Err(MongrelError::DurableCommit {
15536                        epoch: epoch.0,
15537                        message: error.to_string(),
15538                    });
15539                }
15540                Ok(epoch)
15541            })();
15542            let commit_result = if entered_fence {
15543                finish_controlled_commit_attempt(commit_result, &mut after_commit)
15544            } else {
15545                commit_result
15546            };
15547            let epoch = commit_result?;
15548            Ok((column, Some(epoch)))
15549        })();
15550        result.map_err(|error| match (durable_epoch.get(), error) {
15551            (_, error @ MongrelError::DurableCommit { .. }) => error,
15552            (Some(epoch), error) => MongrelError::DurableCommit {
15553                epoch: epoch.0,
15554                message: error.to_string(),
15555            },
15556            (None, error) => error,
15557        })
15558    }
15559
15560    /// Set a timestamp-column TTL policy and WAL-log it for crash recovery and
15561    /// replication. Duration is in nanoseconds.
15562    pub fn set_table_ttl(
15563        &self,
15564        table_name: &str,
15565        column_name: &str,
15566        duration_nanos: u64,
15567    ) -> Result<crate::manifest::TtlPolicy> {
15568        let policy = self.replace_table_ttl(table_name, Some((column_name, duration_nanos)))?;
15569        policy.ok_or_else(|| MongrelError::Other("set TTL produced no policy".into()))
15570    }
15571
15572    /// Set TTL metadata on a hidden build before it is published.
15573    #[doc(hidden)]
15574    pub fn set_building_table_ttl(
15575        &self,
15576        table_name: &str,
15577        column_name: &str,
15578        duration_nanos: u64,
15579    ) -> Result<crate::manifest::TtlPolicy> {
15580        let policy = self.replace_table_ttl_with_state(
15581            table_name,
15582            Some((column_name, duration_nanos)),
15583            true,
15584        )?;
15585        policy
15586            .ok_or_else(|| MongrelError::Other("set building-table TTL produced no policy".into()))
15587    }
15588
15589    pub fn clear_table_ttl(&self, table_name: &str) -> Result<()> {
15590        self.replace_table_ttl(table_name, None)?;
15591        Ok(())
15592    }
15593
15594    fn replace_table_ttl(
15595        &self,
15596        table_name: &str,
15597        requested: Option<(&str, u64)>,
15598    ) -> Result<Option<crate::manifest::TtlPolicy>> {
15599        self.replace_table_ttl_with_state(table_name, requested, false)
15600    }
15601
15602    fn replace_table_ttl_with_state(
15603        &self,
15604        table_name: &str,
15605        requested: Option<(&str, u64)>,
15606        building: bool,
15607    ) -> Result<Option<crate::manifest::TtlPolicy>> {
15608        use crate::wal::DdlOp;
15609        use std::sync::atomic::Ordering;
15610
15611        self.require(&crate::auth::Permission::Ddl)?;
15612        if self.poisoned.load(Ordering::Relaxed) {
15613            return Err(MongrelError::Other(
15614                "database poisoned by fsync error".into(),
15615            ));
15616        }
15617
15618        let _g = self.ddl_lock.lock();
15619        let _security_write = self.security_write()?;
15620        self.require(&crate::auth::Permission::Ddl)?;
15621        let table_id = {
15622            let cat = self.catalog.read();
15623            if building {
15624                cat.building(table_name)
15625            } else {
15626                cat.live(table_name)
15627            }
15628            .ok_or_else(|| MongrelError::NotFound(format!("table {table_name:?} not found")))?
15629            .table_id
15630        };
15631        let handle =
15632            self.tables.read().get(&table_id).cloned().ok_or_else(|| {
15633                MongrelError::NotFound(format!("table {table_name:?} not mounted"))
15634            })?;
15635        let mut table = handle.lock();
15636        let policy = match requested {
15637            Some((column, duration)) => Some(table.prepare_ttl_policy(column, duration)?),
15638            None => None,
15639        };
15640        if table.ttl() == policy {
15641            return Ok(policy);
15642        }
15643
15644        let commit_lock = Arc::clone(&self.commit_lock);
15645        let _c = commit_lock.lock();
15646        let epoch = self.epoch.bump_assigned();
15647        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
15648        let txn_id = self.alloc_txn_id()?;
15649        let policy_json = DdlOp::encode_ttl(policy)?;
15650        let mut next_catalog = self.catalog.read().clone();
15651        next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
15652        let commit_seq = {
15653            let mut wal = self.shared_wal.lock();
15654            let append: Result<u64> = (|| {
15655                wal.append(
15656                    txn_id,
15657                    table_id,
15658                    crate::wal::Op::Ddl(DdlOp::SetTtl {
15659                        table_id,
15660                        policy_json,
15661                    }),
15662                )?;
15663                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
15664                wal.append_commit(txn_id, epoch, &[])
15665            })();
15666            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
15667        };
15668        let receipt = self.await_durable_commit(txn_id, commit_seq, epoch)?;
15669
15670        let mut publish_error = table.apply_ttl_policy_at(policy, epoch).err();
15671        drop(table);
15672        if let Err(error) = self.checkpoint_catalog_after_durable(next_catalog) {
15673            publish_error.get_or_insert(error);
15674        }
15675        self.finish_durable_publish(
15676            epoch,
15677            &mut _epoch_guard,
15678            &receipt,
15679            publish_error.map_or(Ok(()), Err),
15680        )?;
15681        Ok(policy)
15682    }
15683
15684    /// Retention-gated garbage collection (spec §6.4, §7.4, §16). Deletes:
15685    /// - Dropped-table subdirs whose `at_epoch < min_active_snapshot`.
15686    /// - Stale `_txn/` dirs (aborted/crashed large-txn pending runs).
15687    ///
15688    /// Returns the number of items reclaimed.
15689    pub fn gc(&self) -> Result<usize> {
15690        let control = crate::ExecutionControl::new(None);
15691        self.gc_controlled(&control, || true)
15692    }
15693
15694    /// Discover reclaimable state cooperatively, then cross one publication
15695    /// boundary immediately before the first irreversible deletion.
15696    #[doc(hidden)]
15697    pub fn gc_controlled<F>(
15698        &self,
15699        control: &crate::ExecutionControl,
15700        before_publish: F,
15701    ) -> Result<usize>
15702    where
15703        F: FnOnce() -> bool,
15704    {
15705        self.gc_controlled_with_receipt(control, before_publish)
15706            .map(|(reclaimed, _)| reclaimed)
15707    }
15708
15709    /// Discover reclaimable state from one exact catalog/epoch snapshot, then
15710    /// return that snapshot if an irreversible deletion was attempted.
15711    #[doc(hidden)]
15712    pub fn gc_controlled_with_receipt<F>(
15713        &self,
15714        control: &crate::ExecutionControl,
15715        before_publish: F,
15716    ) -> Result<(usize, Option<MaintenanceReceipt>)>
15717    where
15718        F: FnOnce() -> bool,
15719    {
15720        enum Candidate {
15721            Directory(PathBuf),
15722            File(PathBuf),
15723        }
15724
15725        self.require(&crate::auth::Permission::Ddl)?;
15726        // S1A-004: admit the maintenance pass as one core operation.
15727        let _operation = self.admit_operation()?;
15728        let _ddl = self.ddl_lock.lock();
15729        self.require(&crate::auth::Permission::Ddl)?;
15730        control.checkpoint()?;
15731        let maintenance_epoch = self.epoch.visible();
15732        let min_active = self.snapshots.min_active(maintenance_epoch).0;
15733        let mut candidates = Vec::new();
15734
15735        // Reclaim dropped-table dirs where no pinned snapshot still needs them.
15736        let cat = self.catalog.read();
15737        for (entry_index, entry) in cat.tables.iter().enumerate() {
15738            if entry_index % 256 == 0 {
15739                control.checkpoint()?;
15740            }
15741            if let TableState::Dropped { at_epoch } = entry.state {
15742                if at_epoch <= min_active {
15743                    let tdir = self.root.join(TABLES_DIR).join(entry.table_id.to_string());
15744                    if tdir.exists() {
15745                        candidates.push(Candidate::Directory(tdir));
15746                    }
15747                }
15748            }
15749        }
15750        drop(cat);
15751
15752        // Sweep stale _txn/<id>/ dirs on remaining live tables — but NEVER an
15753        // in-flight spill's dir (deleting it would lose the pending run and fail
15754        // the commit, review fix #14). Each `_txn/` subdir is named by its txn id;
15755        // skip any id still registered in `active_spills`.
15756        let cat = self.catalog.read();
15757        for (entry_index, entry) in cat.tables.iter().enumerate() {
15758            if entry_index % 256 == 0 {
15759                control.checkpoint()?;
15760            }
15761            if !matches!(entry.state, TableState::Live) {
15762                continue;
15763            }
15764            let txn_dir = self
15765                .root
15766                .join(TABLES_DIR)
15767                .join(entry.table_id.to_string())
15768                .join("_txn");
15769            if !txn_dir.exists() {
15770                continue;
15771            }
15772            for (sub_index, sub) in std::fs::read_dir(&txn_dir)?.enumerate() {
15773                if sub_index % 256 == 0 {
15774                    control.checkpoint()?;
15775                }
15776                let sub = sub?;
15777                let name = sub.file_name();
15778                let Some(name) = name.to_str() else { continue };
15779                // A non-numeric entry can't belong to a live txn — sweep it.
15780                let is_active = name
15781                    .parse::<u64>()
15782                    .map(|id| self.active_spills.is_active(id))
15783                    .unwrap_or(false);
15784                if is_active {
15785                    continue;
15786                }
15787                candidates.push(Candidate::Directory(sub.path()));
15788            }
15789        }
15790        drop(cat);
15791
15792        let external_names = {
15793            let cat = self.catalog.read();
15794            cat.external_tables
15795                .iter()
15796                .map(|entry| entry.name.clone())
15797                .collect::<std::collections::HashSet<_>>()
15798        };
15799        let vtab_dir = self.root.join(VTAB_DIR);
15800        if vtab_dir.exists() {
15801            for (entry_index, entry) in std::fs::read_dir(&vtab_dir)?.enumerate() {
15802                if entry_index % 256 == 0 {
15803                    control.checkpoint()?;
15804                }
15805                let entry = entry?;
15806                let name = entry.file_name();
15807                let Some(name) = name.to_str() else { continue };
15808                if external_names.contains(name) {
15809                    continue;
15810                }
15811                let path = entry.path();
15812                if path.is_dir() {
15813                    candidates.push(Candidate::Directory(path));
15814                } else {
15815                    candidates.push(Candidate::File(path));
15816                }
15817            }
15818        }
15819
15820        // Reap compaction-superseded runs whose retire epoch no pinned snapshot
15821        // can still need (spec §6.4). Each table deletes its own retired files
15822        // gated on `min_active` and persists its manifest.
15823        let tables = self
15824            .tables
15825            .read()
15826            .iter()
15827            .map(|(table_id, handle)| (*table_id, handle.clone()))
15828            .collect::<Vec<_>>();
15829        let mut retiring = Vec::new();
15830        for (table_index, (table_id, handle)) in tables.iter().enumerate() {
15831            if table_index % 256 == 0 {
15832                control.checkpoint()?;
15833            }
15834            let backup_pinned: HashSet<u128> = self
15835                .backup_pins
15836                .lock()
15837                .keys()
15838                .filter_map(|(pinned_table, run_id)| {
15839                    (*pinned_table == *table_id).then_some(*run_id)
15840                })
15841                .collect();
15842            if handle
15843                .lock()
15844                .has_reapable_retiring(Epoch(min_active), &backup_pinned)
15845            {
15846                retiring.push((handle.clone(), backup_pinned));
15847            }
15848        }
15849
15850        // WAL-segment GC (spec §6.4/§16). `SharedWal::open` mints a fresh active
15851        // segment on every reopen without truncating the prior ones, so rotated
15852        // segments accumulate. Once every live table's committed data is durable
15853        // in runs (no in-memory rows) and no in-flight spill is open, all rotated
15854        // (non-active) segments are redundant for recovery and safe to delete —
15855        // an in-flight txn only ever appends to the active segment, which is
15856        // never deleted.
15857        let all_durable = self.active_spills.is_idle()
15858            && tables.iter().all(|(_, handle)| {
15859                let g = handle.lock();
15860                g.memtable_len() == 0 && g.mutable_run_len() == 0
15861            });
15862        let retain = self
15863            .replication_wal_retention_segments
15864            .load(std::sync::atomic::Ordering::Relaxed);
15865        let reap_wal = all_durable
15866            && self
15867                .shared_wal
15868                .lock()
15869                .has_gc_segments_retain_recent(retain)?;
15870
15871        if candidates.is_empty() && retiring.is_empty() && !reap_wal {
15872            return Ok((0, None));
15873        }
15874        control.checkpoint()?;
15875        if !before_publish() {
15876            return Err(MongrelError::Cancelled);
15877        }
15878
15879        let mut reclaimed = 0;
15880        for candidate in candidates {
15881            match candidate {
15882                Candidate::Directory(path) => std::fs::remove_dir_all(path)?,
15883                Candidate::File(path) => std::fs::remove_file(path)?,
15884            }
15885            reclaimed += 1;
15886        }
15887        for (handle, backup_pinned) in retiring {
15888            reclaimed += handle
15889                .lock()
15890                .reap_retiring(Epoch(min_active), &backup_pinned)?;
15891        }
15892        if reap_wal {
15893            reclaimed += self
15894                .shared_wal
15895                .lock()
15896                .gc_segments_retain_recent(u64::MAX, retain)?;
15897        }
15898
15899        Ok((
15900            reclaimed,
15901            Some(MaintenanceReceipt {
15902                epoch: maintenance_epoch,
15903            }),
15904        ))
15905    }
15906
15907    /// Produce a deterministic-stable byte image of the database directory.
15908    ///
15909    /// After `checkpoint()`:
15910    ///   - All pending writes are flushed to sorted runs (no memtable data).
15911    ///   - Each table is compacted to a single sorted run (no run fragmentation).
15912    ///   - All non-active WAL segments are deleted (data is durable in runs).
15913    ///   - The active WAL segment is rotated to a fresh empty segment.
15914    ///   - Dropped-table directories are removed.
15915    ///   - All manifests + catalog are persisted.
15916    ///
15917    /// The resulting directory is byte-stable: `git add` captures a snapshot
15918    /// that `git checkout` restores deterministically. No stale WAL tail bytes,
15919    /// no unbounded segment growth, no mutable-run spill files.
15920    ///
15921    /// This is the engine primitive behind `mongreldb snapshot <dir>` (CLI).
15922    /// It does NOT clear the exclusive lock — the caller still owns the
15923    /// database handle.
15924    pub fn checkpoint(&self) -> Result<()> {
15925        self.checkpoint_controlled(|| Ok(()))
15926    }
15927
15928    /// Strict checkpoint with a deterministic test hook after every table is
15929    /// flushed/compacted but before WAL replacement.
15930    #[doc(hidden)]
15931    pub fn checkpoint_controlled<F>(&self, before_wal_reset: F) -> Result<()>
15932    where
15933        F: FnOnce() -> Result<()>,
15934    {
15935        self.require(&crate::auth::Permission::Ddl)?;
15936        // S1A-004: admit the checkpoint as one core operation.
15937        let _operation = self.admit_operation()?;
15938        // Block cross-table commits and DDL for the full operation. Locking all
15939        // mounted handles also excludes direct `Table` commits, which do not
15940        // enter the database replication barrier.
15941        let _replication = self.replication_barrier.write();
15942        let _ddl = self.ddl_lock.lock();
15943        let _security = self.security_coordinator.gate.read();
15944        self.require(&crate::auth::Permission::Ddl)?;
15945
15946        let mut handles = self
15947            .tables
15948            .read()
15949            .iter()
15950            .map(|(table_id, handle)| (*table_id, handle.clone()))
15951            .collect::<Vec<_>>();
15952        handles.sort_by_key(|(table_id, _)| *table_id);
15953        let mut tables = handles
15954            .iter()
15955            .map(|(table_id, handle)| (*table_id, handle.lock()))
15956            .collect::<Vec<_>>();
15957
15958        // Strict flush. Any error leaves the old WAL recovery source intact.
15959        for (_, table) in &mut tables {
15960            if table.has_pending_writes() || table.memtable_len() > 0 || table.mutable_run_len() > 0
15961            {
15962                table.force_flush()?;
15963            }
15964        }
15965
15966        // Strict compaction. Checkpoint never reports a stable image after a
15967        // skipped failure.
15968        for (_, table) in &mut tables {
15969            if table.run_count() >= 2 || table.should_compact() {
15970                table.compact()?;
15971            }
15972        }
15973
15974        before_wal_reset()?;
15975
15976        // Reap table-local retired runs while every table remains quiesced.
15977        let maintenance_epoch = self.epoch.visible();
15978        let min_active = self.snapshots.min_active(maintenance_epoch);
15979        for (table_id, table) in &mut tables {
15980            let backup_pinned: HashSet<u128> = self
15981                .backup_pins
15982                .lock()
15983                .keys()
15984                .filter_map(|(pinned_table, run_id)| {
15985                    (*pinned_table == *table_id).then_some(*run_id)
15986                })
15987                .collect();
15988            table.reap_retiring(min_active, &backup_pinned)?;
15989        }
15990
15991        // Publish a fresh synced active WAL, then durably reap every older
15992        // segment. This point is reached only after every strict flush succeeds.
15993        self.shared_wal.lock().reset_after_checkpoint()?;
15994
15995        // Remove catalog-unreachable directories and stale transaction state.
15996        let catalog_snapshot = self.catalog.read().clone();
15997        for entry in &catalog_snapshot.tables {
15998            if matches!(entry.state, TableState::Dropped { at_epoch } if at_epoch <= min_active.0) {
15999                crate::durable_file::remove_directory_all(
16000                    &self.root.join(TABLES_DIR).join(entry.table_id.to_string()),
16001                )?;
16002            }
16003            if !matches!(entry.state, TableState::Live) {
16004                continue;
16005            }
16006            let transaction_dir = self
16007                .root
16008                .join(TABLES_DIR)
16009                .join(entry.table_id.to_string())
16010                .join("_txn");
16011            if transaction_dir.is_dir() {
16012                for child in std::fs::read_dir(&transaction_dir)? {
16013                    let child = child?;
16014                    let active = child
16015                        .file_name()
16016                        .to_str()
16017                        .and_then(|name| name.parse::<u64>().ok())
16018                        .is_some_and(|txn_id| self.active_spills.is_active(txn_id));
16019                    if !active {
16020                        crate::durable_file::remove_directory_all(&child.path())?;
16021                    }
16022                }
16023            }
16024        }
16025        let external_names = catalog_snapshot
16026            .external_tables
16027            .iter()
16028            .map(|entry| entry.name.as_str())
16029            .collect::<HashSet<_>>();
16030        let external_root = self.root.join(VTAB_DIR);
16031        if external_root.is_dir() {
16032            for entry in std::fs::read_dir(&external_root)? {
16033                let entry = entry?;
16034                let name = entry.file_name();
16035                if name
16036                    .to_str()
16037                    .is_some_and(|name| external_names.contains(name))
16038                {
16039                    continue;
16040                }
16041                if entry.file_type()?.is_dir() {
16042                    crate::durable_file::remove_directory_all(&entry.path())?;
16043                } else {
16044                    std::fs::remove_file(entry.path())?;
16045                    crate::durable_file::sync_directory(&external_root)?;
16046                }
16047            }
16048        }
16049
16050        // Final authoritative metadata checkpoint while all writers remain
16051        // excluded.
16052        catalog::write_atomic(&self.root, &catalog_snapshot, self.meta_dek.as_ref())?;
16053        let visible = self.epoch.visible();
16054        for (_, table) in &tables {
16055            table.persist_manifest(visible)?;
16056        }
16057
16058        Ok(())
16059    }
16060    fn alloc_txn_id(&self) -> Result<u64> {
16061        self.ensure_owner_process()?;
16062        crate::txn::allocate_txn_id(&self.next_txn_id)
16063    }
16064
16065    /// Allocate a lock-manager transaction id for SQL `SELECT ... FOR UPDATE`
16066    /// (or other multi-statement lock holds). Released via
16067    /// [`Self::release_txn_locks`].
16068    pub fn allocate_lock_txn_id(&self) -> Result<u64> {
16069        self.alloc_txn_id()
16070    }
16071
16072    /// Set the per-table spill threshold (bytes). When a transaction's staged
16073    /// bytes for a single table exceed this, the rows are written as a
16074    /// uniform-epoch pending run instead of streamed Put records (spec §8.5).
16075    pub fn set_spill_threshold(&self, bytes: u64) {
16076        self.spill_threshold
16077            .store(bytes, std::sync::atomic::Ordering::Relaxed);
16078    }
16079
16080    /// Test-only: install a hook invoked after a transaction writes its spill
16081    /// runs but before the sequencer, so a test can race `gc()` against an
16082    /// in-flight spill. Not part of the stable API.
16083    #[doc(hidden)]
16084    pub fn __set_spill_hook(&self, f: impl Fn() + Send + Sync + 'static) {
16085        *self.spill_hook.lock() = Some(Box::new(f));
16086    }
16087
16088    /// Test-only: install a hook invoked while a spilled commit holds the
16089    /// security read gate and before it appends to the WAL.
16090    #[doc(hidden)]
16091    pub fn __set_security_commit_hook(&self, f: impl Fn() + Send + Sync + 'static) {
16092        *self.security_commit_hook.lock() = Some(Box::new(f));
16093    }
16094
16095    /// Test-only: install a hook after transaction preparation and before the
16096    /// commit sequencer validates catalog generations.
16097    #[doc(hidden)]
16098    pub fn __set_catalog_commit_hook(&self, f: impl Fn() + Send + Sync + 'static) {
16099        *self.catalog_commit_hook.lock() = Some(Box::new(f));
16100    }
16101
16102    /// Test-only: pause an online backup after its consistent boundary is
16103    /// captured but before the pinned immutable runs are copied.
16104    #[doc(hidden)]
16105    pub fn __set_backup_hook(&self, f: impl Fn() + Send + Sync + 'static) {
16106        *self.backup_hook.lock() = Some(Box::new(f));
16107    }
16108
16109    /// Test-only: invoked after each successful FK parent-protection lock
16110    /// acquisition during constraint validation, so tests can rendezvous two
16111    /// committing transactions into a deterministic wait-for cycle.
16112    #[doc(hidden)]
16113    pub fn __set_fk_lock_hook(&self, f: impl Fn() + Send + Sync + 'static) {
16114        *self.fk_lock_hook.lock() = Some(Arc::new(f));
16115    }
16116
16117    /// Test-only: pause WAL extraction before its final principal recheck.
16118    #[doc(hidden)]
16119    pub fn __set_replication_hook(&self, f: impl Fn() + Send + Sync + 'static) {
16120        *self.replication_hook.lock() = Some(Box::new(f));
16121    }
16122
16123    /// Number of WAL fsyncs issued so far (test/diagnostic). With group commit
16124    /// this stays well below the number of committed transactions when commits
16125    /// are concurrent (one leader fsync covers a whole batch — spec §9.3).
16126    #[doc(hidden)]
16127    pub fn __wal_group_sync_count(&self) -> u64 {
16128        self.shared_wal.lock().group_sync_count()
16129    }
16130
16131    /// Force the poisoned state (test-only) to verify the §9.3e fail-fast
16132    /// contract that an fsync error would trigger in production.
16133    #[doc(hidden)]
16134    pub fn __poison(&self) {
16135        self.poisoned
16136            .store(true, std::sync::atomic::Ordering::Relaxed);
16137    }
16138
16139    /// Verify multi-table integrity (spec §16). For every live table this:
16140    /// authenticates the manifest; opens each `RunRef`'s file through
16141    /// [`RunReader`](crate::sorted_run::RunReader), which verifies the run footer
16142    /// checksum and — for encrypted DBs — the keyed run-metadata MAC; checks each
16143    /// run's physical row count against its `RunRef`; flags `RunRef`s whose file
16144    /// is missing (dangling) and `.sr` files on disk that no `RunRef` references
16145    /// (orphan); and verifies `flushed_epoch <= current_epoch`. Returns the list
16146    /// of issues found (empty = healthy). Orphans are `warning`-severity; all
16147    /// other findings are `error`-severity (so [`Self::doctor`] quarantines them).
16148    ///
16149    /// Cost: O(total run bytes) — the footer checksum is verified over each run's
16150    /// full body, so this is an integrity tool, not a hot path.
16151    pub fn check(&self) -> Vec<CheckIssue> {
16152        match self.check_inner(None) {
16153            Ok(issues) => issues,
16154            Err(error) => vec![CheckIssue {
16155                table_id: WAL_TABLE_ID,
16156                table_name: "shared WAL".into(),
16157                severity: "error".into(),
16158                description: error.to_string(),
16159            }],
16160        }
16161    }
16162
16163    /// Integrity check with cooperative cancellation between tables and runs.
16164    #[doc(hidden)]
16165    pub fn check_controlled(&self, control: &crate::ExecutionControl) -> Result<Vec<CheckIssue>> {
16166        self.check_inner(Some(control))
16167    }
16168
16169    fn check_inner(&self, control: Option<&crate::ExecutionControl>) -> Result<Vec<CheckIssue>> {
16170        let mut issues = Vec::new();
16171        let io_root = self.durable_root.io_path()?;
16172        let cat = self.catalog.read();
16173        let manifest_meta_dek = crate::encryption::meta_dek_for(self.kek.as_deref());
16174        for (table_index, entry) in cat.tables.iter().enumerate() {
16175            if table_index % 256 == 0 {
16176                if let Some(control) = control {
16177                    control.checkpoint()?;
16178                }
16179            }
16180            if !matches!(entry.state, TableState::Live) {
16181                continue;
16182            }
16183            let tdir = io_root.join(TABLES_DIR).join(entry.table_id.to_string());
16184            let mut err = |sev: &str, desc: String| {
16185                issues.push(CheckIssue {
16186                    table_id: entry.table_id,
16187                    table_name: entry.name.clone(),
16188                    severity: sev.into(),
16189                    description: desc,
16190                });
16191            };
16192            let m = match crate::manifest::read(&tdir, manifest_meta_dek.as_ref()) {
16193                Ok(m) => m,
16194                Err(e) => {
16195                    err("error", format!("manifest read failed: {e}"));
16196                    continue;
16197                }
16198            };
16199            if m.flushed_epoch > m.current_epoch {
16200                err(
16201                    "error",
16202                    format!(
16203                        "flushed_epoch {} exceeds current_epoch {} (impossible)",
16204                        m.flushed_epoch, m.current_epoch
16205                    ),
16206                );
16207            }
16208
16209            let runs_dir = tdir.join(crate::engine::RUNS_DIR);
16210            let mut referenced: std::collections::HashSet<u128> = std::collections::HashSet::new();
16211            for (run_index, rr) in m.runs.iter().enumerate() {
16212                if run_index % 256 == 0 {
16213                    if let Some(control) = control {
16214                        control.checkpoint()?;
16215                    }
16216                }
16217                referenced.insert(rr.run_id);
16218                let run_path = runs_dir.join(format!("r-{}.sr", rr.run_id));
16219                if !run_path.exists() {
16220                    err("error", format!("missing run file: r-{}.sr", rr.run_id));
16221                    continue;
16222                }
16223                match crate::sorted_run::RunReader::open(
16224                    &run_path,
16225                    entry.schema.clone(),
16226                    self.kek.clone(),
16227                ) {
16228                    Ok(reader) => {
16229                        if reader.row_count() as u64 != rr.row_count {
16230                            err(
16231                                "error",
16232                                format!(
16233                                    "run r-{} row count mismatch: manifest {} vs run {}",
16234                                    rr.run_id,
16235                                    rr.row_count,
16236                                    reader.row_count()
16237                                ),
16238                            );
16239                        }
16240                    }
16241                    Err(e) => {
16242                        err(
16243                            "error",
16244                            format!("run r-{} integrity check failed: {e}", rr.run_id),
16245                        );
16246                    }
16247                }
16248            }
16249
16250            // Compaction-superseded runs awaiting retention-gated deletion are
16251            // tracked in `retiring`; their files are expected on disk, so they
16252            // are not orphans.
16253            for r in &m.retiring {
16254                referenced.insert(r.run_id);
16255            }
16256
16257            // Orphan `.sr` files present on disk but absent from the manifest.
16258            if let Ok(rd) = std::fs::read_dir(&runs_dir) {
16259                for (entry_index, ent) in rd.flatten().enumerate() {
16260                    if entry_index % 256 == 0 {
16261                        if let Some(control) = control {
16262                            control.checkpoint()?;
16263                        }
16264                    }
16265                    let p = ent.path();
16266                    if p.extension().and_then(|s| s.to_str()) != Some("sr") {
16267                        continue;
16268                    }
16269                    let run_id = p
16270                        .file_stem()
16271                        .and_then(|s| s.to_str())
16272                        .and_then(|s| s.strip_prefix("r-"))
16273                        .and_then(|s| s.parse::<u128>().ok());
16274                    if let Some(id) = run_id {
16275                        if !referenced.contains(&id) {
16276                            err(
16277                                "warning",
16278                                format!("orphan run file r-{id}.sr not referenced by the manifest"),
16279                            );
16280                        }
16281                    }
16282                }
16283            }
16284        }
16285
16286        let external_names = cat
16287            .external_tables
16288            .iter()
16289            .map(|entry| entry.name.clone())
16290            .collect::<std::collections::HashSet<_>>();
16291        let vtab_dir = io_root.join(VTAB_DIR);
16292        if let Ok(entries) = std::fs::read_dir(&vtab_dir) {
16293            for (entry_index, entry) in entries.flatten().enumerate() {
16294                if entry_index % 256 == 0 {
16295                    if let Some(control) = control {
16296                        control.checkpoint()?;
16297                    }
16298                }
16299                let name = entry.file_name();
16300                let Some(name) = name.to_str() else { continue };
16301                if !external_names.contains(name) {
16302                    issues.push(CheckIssue {
16303                        table_id: EXTERNAL_TABLE_ID,
16304                        table_name: name.to_string(),
16305                        severity: "warning".into(),
16306                        description: format!(
16307                            "orphan external table state entry {:?} not referenced by the catalog",
16308                            entry.path()
16309                        ),
16310                    });
16311                }
16312            }
16313        }
16314
16315        // WAL retention / integrity invariant (spec §16): every on-disk WAL
16316        // segment must open (header magic + version, and the frame cipher must
16317        // be derivable for an encrypted WAL). A segment that won't open is
16318        // corrupt or truncated and would break crash recovery. `table_id` is
16319        // the reserved `WAL_TABLE_ID` sentinel (u64::MAX) so [`Self::doctor`]
16320        // never confuses a WAL issue with a real table.
16321        if let Some(control) = control {
16322            control.checkpoint()?;
16323        }
16324        for (seg, msg) in self.shared_wal.lock().verify_segments() {
16325            issues.push(CheckIssue {
16326                table_id: WAL_TABLE_ID,
16327                table_name: "<wal>".into(),
16328                severity: "error".into(),
16329                description: format!("WAL segment seg-{seg:06}.wal failed integrity check: {msg}"),
16330            });
16331        }
16332        Ok(issues)
16333    }
16334
16335    /// Quarantine unreadable tables (spec §16). Moves corrupt table dirs to
16336    /// `_quarantine/<table_id>/`, marks them dropped in the catalog, and
16337    /// unmounts them from the live table map so the DB still opens.
16338    pub fn doctor(&self) -> Result<Vec<u64>> {
16339        let control = crate::ExecutionControl::new(None);
16340        self.doctor_controlled(&control, || true)
16341    }
16342
16343    /// Check cancellably, then fence immediately before the first quarantine
16344    /// mutation. Returning `false` from `before_publish` leaves the database
16345    /// untouched.
16346    #[doc(hidden)]
16347    pub fn doctor_controlled<F>(
16348        &self,
16349        control: &crate::ExecutionControl,
16350        before_publish: F,
16351    ) -> Result<Vec<u64>>
16352    where
16353        F: FnOnce() -> bool,
16354    {
16355        self.doctor_controlled_with_receipt(control, before_publish)
16356            .map(|(quarantined, _)| quarantined)
16357    }
16358
16359    /// Check cancellably and return the exact catalog epoch used for a
16360    /// quarantine publication. No receipt is returned when nothing changes.
16361    #[doc(hidden)]
16362    pub fn doctor_controlled_with_receipt<F>(
16363        &self,
16364        control: &crate::ExecutionControl,
16365        before_publish: F,
16366    ) -> Result<(Vec<u64>, Option<MaintenanceReceipt>)>
16367    where
16368        F: FnOnce() -> bool,
16369    {
16370        // Hold the DDL lock for the whole operation to prevent concurrent
16371        // create_table/drop_table from racing the catalog/dir mutation.
16372        let _ddl = self.ddl_lock.lock();
16373        let _security_write = self.security_write()?;
16374        let issues = self.check_inner(Some(control))?;
16375        // A corrupt WAL segment is reported as an error but is NOT a table
16376        // problem — quarantining an innocent table cannot fix it (and the first
16377        // real table is id 0, so the WAL sentinel WAL_TABLE_ID = u64::MAX keeps
16378        // them disjoint). The admin must address WAL corruption manually.
16379        let bad_tables: std::collections::HashSet<u64> = issues
16380            .iter()
16381            .filter(|i| {
16382                i.severity == "error"
16383                    && i.table_id != WAL_TABLE_ID
16384                    && i.table_id != EXTERNAL_TABLE_ID
16385            })
16386            .map(|i| i.table_id)
16387            .collect();
16388        if bad_tables.is_empty() {
16389            return Ok((Vec::new(), None));
16390        }
16391        let _commit = self.commit_lock.lock();
16392        control.checkpoint()?;
16393        if !before_publish() {
16394            return Err(MongrelError::Cancelled);
16395        }
16396        let maintenance_epoch = self.epoch.bump_assigned();
16397        let mut epoch_guard = EpochGuard::new(self.epoch.as_ref(), maintenance_epoch);
16398
16399        let qdir = self.root.join("_quarantine");
16400        crate::durable_file::create_directory(&qdir)?;
16401        let mut bad_tables = bad_tables.into_iter().collect::<Vec<_>>();
16402        bad_tables.sort_unstable();
16403
16404        // Quiesce every mounted target before catalog publication. Existing
16405        // handle clones are marked unavailable in the publication callback so
16406        // they cannot append to the shared WAL after their catalog entry drops.
16407        let mut handles = self
16408            .tables
16409            .read()
16410            .iter()
16411            .filter(|(table_id, _)| bad_tables.binary_search(table_id).is_ok())
16412            .map(|(table_id, handle)| (*table_id, handle.clone()))
16413            .collect::<Vec<_>>();
16414        handles.sort_by_key(|(table_id, _)| *table_id);
16415        let mut table_guards = handles
16416            .iter()
16417            .map(|(table_id, handle)| (*table_id, handle.lock()))
16418            .collect::<Vec<_>>();
16419
16420        let mut next_catalog = self.catalog.read().clone();
16421        for table_id in &bad_tables {
16422            if let Some(entry) = next_catalog
16423                .tables
16424                .iter_mut()
16425                .find(|entry| entry.table_id == *table_id)
16426            {
16427                entry.state = TableState::Dropped {
16428                    at_epoch: maintenance_epoch.0,
16429                };
16430            }
16431        }
16432        next_catalog.db_epoch = next_catalog.db_epoch.max(maintenance_epoch.0);
16433
16434        let txn_id = self.alloc_txn_id()?;
16435        let commit_seq = {
16436            let mut wal = self.shared_wal.lock();
16437            let append: Result<u64> = (|| {
16438                for table_id in &bad_tables {
16439                    wal.append(
16440                        txn_id,
16441                        *table_id,
16442                        crate::wal::Op::Ddl(crate::wal::DdlOp::DropTable {
16443                            table_id: *table_id,
16444                        }),
16445                    )?;
16446                }
16447                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
16448                wal.append_commit(txn_id, maintenance_epoch, &[])
16449            })();
16450            append.map_err(|error| self.commit_outcome_unknown(maintenance_epoch, error))?
16451        };
16452        let receipt = self.await_durable_commit(txn_id, commit_seq, maintenance_epoch)?;
16453        for (_, table) in &mut table_guards {
16454            table.mark_unavailable_after_quarantine();
16455        }
16456        {
16457            let mut live_tables = self.tables.write();
16458            for table_id in &bad_tables {
16459                live_tables.remove(table_id);
16460            }
16461        }
16462        let checkpoint = self.checkpoint_catalog_after_durable(next_catalog);
16463        self.finish_durable_publish(maintenance_epoch, &mut epoch_guard, &receipt, checkpoint)?;
16464
16465        // Release DOCTOR's own table guards and handle clones before moving
16466        // the directory. Windows refuses to rename files held open by the
16467        // final mounted Table instance.
16468        drop(table_guards);
16469        drop(handles);
16470
16471        // The catalog drop is durable. Directory placement is secondary but
16472        // still uses a write-through rename. A failure reports the known
16473        // catalog outcome and leaves a harmless orphan under `tables/`.
16474        for table_id in &bad_tables {
16475            let source = self.root.join(TABLES_DIR).join(table_id.to_string());
16476            if source.exists() {
16477                let destination = qdir.join(table_id.to_string());
16478                if let Err(error) = crate::durable_file::rename(&source, &destination) {
16479                    return Err(MongrelError::DurableCommit {
16480                        epoch: maintenance_epoch.0,
16481                        message: format!(
16482                            "DOCTOR dropped table {table_id} but quarantine move failed: {error}"
16483                        ),
16484                    });
16485                }
16486            }
16487        }
16488        Ok((
16489            bad_tables,
16490            Some(MaintenanceReceipt {
16491                epoch: maintenance_epoch,
16492            }),
16493        ))
16494    }
16495
16496    /// The DB-wide KEK (if encrypted).
16497    #[allow(dead_code)]
16498    pub(crate) fn kek(&self) -> Option<&Arc<crate::encryption::Kek>> {
16499        self.kek.as_ref()
16500    }
16501
16502    /// Shared epoch authority (used by the transaction layer in P2).
16503    #[allow(dead_code)]
16504    pub(crate) fn epoch_authority(&self) -> &Arc<EpochAuthority> {
16505        &self.epoch
16506    }
16507
16508    /// Shared snapshot registry (used by GC in P3.6).
16509    #[allow(dead_code)]
16510    pub(crate) fn snapshots(&self) -> &Arc<SnapshotRegistry> {
16511        &self.snapshots
16512    }
16513}
16514
16515fn external_state_dir(root: &Path, name: &str) -> PathBuf {
16516    root.join(VTAB_DIR).join(name)
16517}
16518
16519fn append_catalog_snapshot(
16520    wal: &mut crate::wal::SharedWal,
16521    txn_id: u64,
16522    catalog: &Catalog,
16523) -> Result<()> {
16524    let catalog_json = crate::wal::DdlOp::encode_catalog(catalog)?;
16525    wal.append(
16526        txn_id,
16527        WAL_TABLE_ID,
16528        crate::wal::Op::Ddl(crate::wal::DdlOp::CatalogSnapshot { catalog_json }),
16529    )?;
16530    Ok(())
16531}
16532
16533fn filter_ignored_staging(
16534    staging: Vec<(u64, crate::txn::Staged)>,
16535    ignored_indices: &std::collections::BTreeSet<usize>,
16536) -> Vec<(u64, crate::txn::Staged)> {
16537    if ignored_indices.is_empty() {
16538        return staging;
16539    }
16540    staging
16541        .into_iter()
16542        .enumerate()
16543        .filter_map(|(idx, staged)| (!ignored_indices.contains(&idx)).then_some(staged))
16544        .collect()
16545}
16546
16547fn external_state_file(root: &Path, name: &str) -> PathBuf {
16548    external_state_dir(root, name).join("state.json")
16549}
16550
16551fn read_external_state_file(root: &Path, name: &str) -> Result<Vec<u8>> {
16552    let path = external_state_file(root, name);
16553    match std::fs::read(path) {
16554        Ok(bytes) => Ok(bytes),
16555        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
16556        Err(e) => Err(e.into()),
16557    }
16558}
16559
16560fn current_external_state_bytes(
16561    root: &Path,
16562    external_states: &[(String, Vec<u8>)],
16563    name: &str,
16564) -> Result<Vec<u8>> {
16565    for (table, state) in external_states.iter().rev() {
16566        if table == name {
16567            return Ok(state.clone());
16568        }
16569    }
16570    read_external_state_file(root, name)
16571}
16572
16573fn dedup_external_states(external_states: Vec<(String, Vec<u8>)>) -> Vec<(String, Vec<u8>)> {
16574    let mut out = external_states;
16575    dedup_external_states_in_place(&mut out);
16576    out
16577}
16578
16579fn dedup_external_states_in_place(external_states: &mut Vec<(String, Vec<u8>)>) {
16580    let mut seen = std::collections::HashSet::new();
16581    let mut out = Vec::with_capacity(external_states.len());
16582    for (name, state) in std::mem::take(external_states).into_iter().rev() {
16583        if seen.insert(name.clone()) {
16584            out.push((name, state));
16585        }
16586    }
16587    out.reverse();
16588    *external_states = out;
16589}
16590
16591fn prepare_external_state_file(
16592    root: &Path,
16593    name: &str,
16594    state: &[u8],
16595    txn_id: u64,
16596) -> Result<PathBuf> {
16597    crate::durable_file::create_directory(&root.join(VTAB_DIR))?;
16598    let dir = external_state_dir(root, name);
16599    crate::durable_file::create_directory(&dir)?;
16600    let pending = dir.join(format!("state.json.{txn_id}.tmp"));
16601    {
16602        let mut file = std::fs::OpenOptions::new()
16603            .create_new(true)
16604            .write(true)
16605            .open(&pending)?;
16606        file.write_all(state)?;
16607        file.sync_all()?;
16608    }
16609    Ok(pending)
16610}
16611
16612fn publish_external_state_file(root: &Path, name: &str, pending: &Path) -> Result<()> {
16613    let path = external_state_file(root, name);
16614    crate::durable_file::replace(pending, &path)?;
16615    Ok(())
16616}
16617
16618fn write_external_state_file(
16619    durable: &crate::durable_file::DurableRoot,
16620    name: &str,
16621    state: &[u8],
16622) -> Result<()> {
16623    let directory = Path::new(VTAB_DIR).join(name);
16624    durable.create_directory_all(&directory)?;
16625    durable.write_atomic(directory.join("state.json"), state)?;
16626    Ok(())
16627}
16628
16629fn validate_recovered_data_table(
16630    catalog: &Catalog,
16631    tables: &HashMap<u64, TableHandle>,
16632    table_id: u64,
16633    commit_epoch: u64,
16634    offset: u64,
16635) -> Result<bool> {
16636    let entry = catalog
16637        .tables
16638        .iter()
16639        .find(|entry| entry.table_id == table_id)
16640        .ok_or_else(|| MongrelError::CorruptWal {
16641            offset,
16642            reason: format!("committed record references unknown table {table_id}"),
16643        })?;
16644    if commit_epoch < entry.created_epoch {
16645        return Err(MongrelError::CorruptWal {
16646            offset,
16647            reason: format!(
16648                "table {table_id} record epoch {commit_epoch} precedes creation epoch {}",
16649                entry.created_epoch
16650            ),
16651        });
16652    }
16653    match entry.state {
16654        TableState::Dropped { at_epoch } => {
16655            // Abandoned hidden builds are marked dropped at the last durable
16656            // boundary during open, so their final build commit may equal the
16657            // cleanup epoch. Ordinary table drops consume a new epoch and must
16658            // remain strictly later than every data commit.
16659            let abandoned_build_boundary =
16660                entry.name.starts_with(CTAS_BUILD_TABLE_PREFIX) && commit_epoch == at_epoch;
16661            if commit_epoch >= at_epoch && !abandoned_build_boundary {
16662                Err(MongrelError::CorruptWal {
16663                    offset,
16664                    reason: format!(
16665                        "table {table_id} record epoch {commit_epoch} is not before drop epoch {at_epoch}"
16666                    ),
16667                })
16668            } else {
16669                Ok(false)
16670            }
16671        }
16672        TableState::Live | TableState::Building { .. } => {
16673            if tables.contains_key(&table_id) {
16674                Ok(true)
16675            } else {
16676                Err(MongrelError::CorruptWal {
16677                    offset,
16678                    reason: format!("live table {table_id} has no mounted recovery handle"),
16679                })
16680            }
16681        }
16682    }
16683}
16684
16685type RecoveryTableStage = (
16686    Vec<crate::memtable::Row>,
16687    Vec<(crate::rowid::RowId, Epoch)>,
16688    Option<Epoch>,
16689    Epoch,
16690);
16691
16692#[derive(Clone)]
16693struct RecoveryValidationTable {
16694    schema: Schema,
16695    flushed_epoch: u64,
16696}
16697
16698fn validate_shared_wal_recovery_plan(
16699    durable_root: &crate::durable_file::DurableRoot,
16700    catalog: &Catalog,
16701    recovered_table_ids: &HashSet<u64>,
16702    reconciled_table_ids: &HashSet<u64>,
16703    meta_dek: Option<&[u8; META_DEK_LEN]>,
16704    kek: Option<Arc<crate::encryption::Kek>>,
16705    records: &[crate::wal::Record],
16706) -> Result<()> {
16707    use crate::wal::{DdlOp, Op};
16708
16709    let mut tables = HashMap::<u64, RecoveryValidationTable>::new();
16710    for entry in &catalog.tables {
16711        if !matches!(entry.state, TableState::Live) {
16712            continue;
16713        }
16714        let relative_dir = Path::new(TABLES_DIR).join(entry.table_id.to_string());
16715        let manifest = match crate::manifest::read_durable(durable_root, &relative_dir, meta_dek) {
16716            Ok(manifest) => Some(manifest),
16717            Err(MongrelError::Io(error)) if error.kind() == std::io::ErrorKind::NotFound => None,
16718            Err(error) => return Err(error),
16719        };
16720        let flushed_epoch = if let Some(manifest) = manifest {
16721            if manifest.table_id != entry.table_id {
16722                return Err(MongrelError::Conflict(format!(
16723                    "catalog table {} storage identity mismatch",
16724                    entry.table_id
16725                )));
16726            }
16727            if (manifest.schema_id != entry.schema.schema_id
16728                && !reconciled_table_ids.contains(&entry.table_id))
16729                || manifest.flushed_epoch > manifest.current_epoch
16730                || manifest.global_idx_epoch > manifest.current_epoch
16731                || manifest.next_row_id == u64::MAX
16732                || manifest.auto_inc_next < 0
16733                || manifest.auto_inc_next == i64::MAX
16734                || (entry.schema.auto_increment_column().is_none() && manifest.auto_inc_next != 0)
16735            {
16736                return Err(MongrelError::InvalidArgument(format!(
16737                    "table {} manifest counters or schema identity are invalid",
16738                    entry.table_id
16739                )));
16740            }
16741            let idx_dek = kek.as_ref().map(|key| key.derive_idx_key());
16742            crate::global_idx::read_durable_for(
16743                durable_root,
16744                &relative_dir,
16745                entry.table_id,
16746                &entry.schema,
16747                idx_dek.as_deref(),
16748            )?;
16749            let mut run_ids = HashSet::new();
16750            let mut maximum_row_id = None::<u64>;
16751            for run in &manifest.runs {
16752                if run.run_id >= u64::MAX as u128
16753                    || run.epoch_created > manifest.current_epoch
16754                    || !run_ids.insert(run.run_id)
16755                {
16756                    return Err(MongrelError::InvalidArgument(format!(
16757                        "table {} manifest contains an invalid or duplicate run id",
16758                        entry.table_id
16759                    )));
16760                }
16761                let relative = relative_dir
16762                    .join(crate::engine::RUNS_DIR)
16763                    .join(format!("r-{}.sr", run.run_id as u64));
16764                let file = durable_root.open_regular(&relative)?;
16765                let mut reader = crate::sorted_run::RunReader::open_file(
16766                    file,
16767                    entry.schema.clone(),
16768                    kek.clone(),
16769                )?;
16770                let header = reader.header();
16771                if header.run_id != run.run_id
16772                    || header.level != run.level
16773                    || header.row_count != run.row_count
16774                    || !header.is_uniform_epoch() && header.epoch_created != run.epoch_created
16775                    || header.is_uniform_epoch() && header.epoch_created != 0
16776                    || header.schema_id > entry.schema.schema_id
16777                {
16778                    return Err(MongrelError::InvalidArgument(format!(
16779                        "table {} run {} differs from its manifest: header=(id {}, level {}, rows {}, epoch {}, schema {}), manifest=(id {}, level {}, rows {}, epoch {}, schema <= {})",
16780                        entry.table_id,
16781                        run.run_id,
16782                        header.run_id,
16783                        header.level,
16784                        header.row_count,
16785                        header.epoch_created,
16786                        header.schema_id,
16787                        run.run_id,
16788                        run.level,
16789                        run.row_count,
16790                        run.epoch_created,
16791                        entry.schema.schema_id,
16792                    )));
16793                }
16794                if header.row_count != 0 {
16795                    maximum_row_id = Some(
16796                        maximum_row_id
16797                            .map_or(header.max_row_id, |value| value.max(header.max_row_id)),
16798                    );
16799                }
16800                reader.validate_all_pages()?;
16801            }
16802            if maximum_row_id.is_some_and(|maximum| manifest.next_row_id <= maximum) {
16803                return Err(MongrelError::InvalidArgument(format!(
16804                    "table {} next_row_id does not advance beyond persisted rows",
16805                    entry.table_id
16806                )));
16807            }
16808            for run in &manifest.retiring {
16809                if run.run_id >= u64::MAX as u128
16810                    || run.retire_epoch > manifest.current_epoch
16811                    || !run_ids.insert(run.run_id)
16812                {
16813                    return Err(MongrelError::InvalidArgument(format!(
16814                        "table {} manifest contains an invalid or aliased retired run",
16815                        entry.table_id
16816                    )));
16817                }
16818            }
16819            manifest.flushed_epoch
16820        } else {
16821            if !recovered_table_ids.contains(&entry.table_id) {
16822                return Err(MongrelError::NotFound(format!(
16823                    "live table {} manifest is missing",
16824                    entry.table_id
16825                )));
16826            }
16827            0
16828        };
16829        tables.insert(
16830            entry.table_id,
16831            RecoveryValidationTable {
16832                schema: entry.schema.clone(),
16833                flushed_epoch,
16834            },
16835        );
16836    }
16837
16838    let committed = records
16839        .iter()
16840        .filter_map(|record| match record.op {
16841            Op::TxnCommit { epoch, .. } => Some((record.txn_id, epoch)),
16842            _ => None,
16843        })
16844        .collect::<HashMap<_, _>>();
16845    let mut run_ids = HashSet::new();
16846    let mut recovered_row_ids = HashMap::<u64, HashSet<u64>>::new();
16847    for record in records {
16848        let Some(&commit_epoch) = committed.get(&record.txn_id) else {
16849            continue;
16850        };
16851        match &record.op {
16852            Op::Put { table_id, rows } => {
16853                let table = validate_recovery_data_table_plan(
16854                    catalog,
16855                    &tables,
16856                    *table_id,
16857                    commit_epoch,
16858                    record.seq.0,
16859                )?;
16860                let decoded: Vec<crate::memtable::Row> =
16861                    bincode::deserialize(rows).map_err(|error| MongrelError::CorruptWal {
16862                        offset: record.seq.0,
16863                        reason: format!(
16864                            "committed Put payload for transaction {} could not be decoded: {error}",
16865                            record.txn_id
16866                        ),
16867                    })?;
16868                if let Some(table) = table {
16869                    for row in &decoded {
16870                        if !recovered_row_ids
16871                            .entry(*table_id)
16872                            .or_default()
16873                            .insert(row.row_id.0)
16874                        {
16875                            return Err(MongrelError::CorruptWal {
16876                                offset: record.seq.0,
16877                                reason: format!(
16878                                    "committed WAL repeats recovered row id {} for table {table_id}",
16879                                    row.row_id.0
16880                                ),
16881                            });
16882                        }
16883                        validate_recovered_row(&table.schema, row)?;
16884                    }
16885                }
16886            }
16887            Op::Delete { table_id, .. } | Op::TruncateTable { table_id } => {
16888                validate_recovery_data_table_plan(
16889                    catalog,
16890                    &tables,
16891                    *table_id,
16892                    commit_epoch,
16893                    record.seq.0,
16894                )?;
16895            }
16896            Op::ExternalTableState { name, .. } => validate_recovered_external_name(name)?,
16897            Op::Ddl(DdlOp::ResetExternalTableState {
16898                name,
16899                generation_epoch,
16900            }) => {
16901                if *generation_epoch != commit_epoch {
16902                    return Err(MongrelError::CorruptWal {
16903                        offset: record.seq.0,
16904                        reason: format!(
16905                            "external state reset epoch {generation_epoch} does not match WAL commit epoch {commit_epoch}"
16906                        ),
16907                    });
16908                }
16909                validate_recovered_external_name(name)?;
16910            }
16911            Op::TxnCommit { added_runs, .. } => {
16912                for added in added_runs {
16913                    let Some(table) = validate_recovery_data_table_plan(
16914                        catalog,
16915                        &tables,
16916                        added.table_id,
16917                        commit_epoch,
16918                        record.seq.0,
16919                    )?
16920                    else {
16921                        continue;
16922                    };
16923                    if added.run_id >= u64::MAX as u128
16924                        || !run_ids.insert((added.table_id, added.run_id))
16925                    {
16926                        return Err(MongrelError::CorruptWal {
16927                            offset: record.seq.0,
16928                            reason: format!(
16929                                "duplicate or invalid recovered run {} for table {}",
16930                                added.run_id, added.table_id
16931                            ),
16932                        });
16933                    }
16934                    if commit_epoch <= table.flushed_epoch {
16935                        continue;
16936                    }
16937                    validate_planned_spilled_run(
16938                        durable_root,
16939                        record.txn_id,
16940                        commit_epoch,
16941                        added,
16942                        &table.schema,
16943                        kek.clone(),
16944                    )?;
16945                }
16946            }
16947            _ => {}
16948        }
16949    }
16950    Ok(())
16951}
16952
16953fn validate_recovery_data_table_plan<'a>(
16954    catalog: &Catalog,
16955    tables: &'a HashMap<u64, RecoveryValidationTable>,
16956    table_id: u64,
16957    commit_epoch: u64,
16958    offset: u64,
16959) -> Result<Option<&'a RecoveryValidationTable>> {
16960    let entry = catalog
16961        .tables
16962        .iter()
16963        .find(|entry| entry.table_id == table_id)
16964        .ok_or_else(|| MongrelError::CorruptWal {
16965            offset,
16966            reason: format!("committed record references unknown table {table_id}"),
16967        })?;
16968    if commit_epoch < entry.created_epoch {
16969        return Err(MongrelError::CorruptWal {
16970            offset,
16971            reason: format!(
16972                "table {table_id} record epoch {commit_epoch} precedes creation epoch {}",
16973                entry.created_epoch
16974            ),
16975        });
16976    }
16977    match entry.state {
16978        TableState::Dropped { at_epoch } => {
16979            let abandoned =
16980                entry.name.starts_with(CTAS_BUILD_TABLE_PREFIX) && commit_epoch == at_epoch;
16981            if commit_epoch >= at_epoch && !abandoned {
16982                return Err(MongrelError::CorruptWal {
16983                    offset,
16984                    reason: format!(
16985                        "table {table_id} record epoch {commit_epoch} is not before drop epoch {at_epoch}"
16986                    ),
16987                });
16988            }
16989            Ok(None)
16990        }
16991        TableState::Live => {
16992            tables
16993                .get(&table_id)
16994                .map(Some)
16995                .ok_or_else(|| MongrelError::CorruptWal {
16996                    offset,
16997                    reason: format!("live table {table_id} has no recovery plan"),
16998                })
16999        }
17000        TableState::Building { .. } => Err(MongrelError::CorruptWal {
17001            offset,
17002            reason: format!("building table {table_id} was not normalized before recovery"),
17003        }),
17004    }
17005}
17006
17007fn validate_planned_spilled_run(
17008    root: &crate::durable_file::DurableRoot,
17009    txn_id: u64,
17010    commit_epoch: u64,
17011    added: &crate::wal::AddedRun,
17012    schema: &Schema,
17013    kek: Option<Arc<crate::encryption::Kek>>,
17014) -> Result<()> {
17015    let table = Path::new(TABLES_DIR).join(added.table_id.to_string());
17016    let destination = table
17017        .join(crate::engine::RUNS_DIR)
17018        .join(format!("r-{}.sr", added.run_id as u64));
17019    let pending = table
17020        .join("_txn")
17021        .join(txn_id.to_string())
17022        .join(format!("r-{}.sr", added.run_id as u64));
17023    let file = match root.open_regular(&destination) {
17024        Ok(file) => file,
17025        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
17026            root.open_regular(&pending).map_err(|pending_error| {
17027                if pending_error.kind() == std::io::ErrorKind::NotFound {
17028                    MongrelError::CorruptWal {
17029                        offset: commit_epoch,
17030                        reason: format!(
17031                            "committed spilled run {} for transaction {txn_id} is missing",
17032                            added.run_id
17033                        ),
17034                    }
17035                } else {
17036                    pending_error.into()
17037                }
17038            })?
17039        }
17040        Err(error) => return Err(error.into()),
17041    };
17042    let mut reader = crate::sorted_run::RunReader::open_file(file, schema.clone(), kek)?;
17043    let header = reader.header();
17044    if header.run_id != added.run_id
17045        || header.content_hash != added.content_hash
17046        || header.row_count != added.row_count
17047        || header.level != added.level
17048        || header.min_row_id != added.min_row_id
17049        || header.max_row_id != added.max_row_id
17050        || header.schema_id != schema.schema_id
17051        || !header.is_uniform_epoch()
17052        || header.epoch_created != 0
17053    {
17054        return Err(MongrelError::CorruptWal {
17055            offset: commit_epoch,
17056            reason: format!(
17057                "committed spilled run {} metadata differs from WAL",
17058                added.run_id
17059            ),
17060        });
17061    }
17062    reader.validate_all_pages()?;
17063    Ok(())
17064}
17065
17066/// Two-pass, `flushed_epoch`-gated recovery of the shared WAL (spec §15).
17067///
17068/// Pass 1 scans every `TxnCommit` marker and records `txn_id → commit_epoch`
17069/// (the per-txn outcome; aborted / in-flight / torn-tail txns are absent). Pass
17070/// 2 applies each committed data record (Put/Delete) to its table at the commit
17071/// epoch, skipping records whose `commit_epoch <= table.flushed_epoch` (already
17072/// durable in a sorted run). Finally the shared epoch authority is raised to the
17073/// max committed epoch so the next commit continues monotonically.
17074/// The staged-write payload contract of a distributed-transaction write
17075/// intent (spec section 12.8). A participant in two-phase commit stages its
17076/// writes as opaque intent payloads (`WriteIntent::value_ref` in
17077/// `mongreldb-cluster::dist_txn`); the intent layer never interprets them —
17078/// this engine-defined encoding is the whole contract. At prepare time the
17079/// payloads are validated ([`Database::validate_staged_txn_writes`]); at a
17080/// committed resolution they are applied through
17081/// [`Database::apply_staged_txn_writes`].
17082///
17083/// The encoding is bincode over this enum (the same codec the WAL frame
17084/// payloads use); discriminants are never reused.
17085#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
17086pub enum StagedTxnWrite {
17087    /// Staged row puts: bincode-serialized `Vec<crate::memtable::Row>` (the
17088    /// identical payload shape an `Op::Put` WAL record carries). Row commit
17089    /// epochs are placeholders — the resolution apply restamps every row at
17090    /// the synthetic commit epoch.
17091    Put {
17092        /// The mounted table the rows target.
17093        table_id: u64,
17094        /// Bincode `Vec<crate::memtable::Row>`.
17095        rows: Vec<u8>,
17096    },
17097    /// Staged row deletes by row id.
17098    Delete {
17099        /// The mounted table the deletes target.
17100        table_id: u64,
17101        /// Row ids (`crate::RowId` values) to delete.
17102        row_ids: Vec<u64>,
17103    },
17104}
17105
17106impl StagedTxnWrite {
17107    /// Serializes deterministically (bincode over the enum).
17108    pub fn encode(&self) -> Result<Vec<u8>> {
17109        Ok(bincode::serialize(self)?)
17110    }
17111
17112    /// Decodes one staged-write payload, failing closed on malformed input.
17113    pub fn decode(bytes: &[u8]) -> Result<Self> {
17114        Ok(bincode::deserialize(bytes)?)
17115    }
17116}
17117
17118/// Leader-side spill translation for the replicated write path (spec section
17119/// 11.3 step 3, "leader constructs transaction command"; review finding M2).
17120///
17121/// A transaction whose staged puts exceed the spill threshold commits with
17122/// its rows in a leader-local sorted run: the commit marker's `added_runs`
17123/// links the run file and the rows also ride the WAL as logical
17124/// `Op::SpilledRows` records (spec section 8.5). Run files exist only on the
17125/// leader, so a commit carrying `added_runs` is un-appliable on a replica —
17126/// and because the raft entry is already quorum-committed, an apply-time
17127/// rejection wedges the whole group's apply stream. The leader therefore
17128/// translates the staged record sequence **before proposal**:
17129///
17130/// - every `Op::SpilledRows` payload is re-tagged as an ordinary `Op::Put`
17131///   (identical row bytes; recovery restamps the rows at the commit epoch),
17132/// - the trailing `Op::TxnCommit` loses its `added_runs` (no run links ever
17133///   reach a replica),
17134/// - every other record passes through byte-identical.
17135///
17136/// The standalone commit path is untouched: the leader's own WAL keeps the
17137/// original sequence (`SpilledRows` + `added_runs`) so its recovery still
17138/// links the run; this function reads but never mutates its input.
17139///
17140/// Translation is total for any sequence the commit sequencer actually
17141/// produced. As a fail-closed guard against malformed or truncated captures,
17142/// the sequence is structurally validated (one transaction, exactly one
17143/// trailing commit marker), every spill payload must decode, and every linked
17144/// run's rows must be provably present as logical records (the row-id range
17145/// covers `row_count` rows); a violation rejects the proposal with
17146/// [`MongrelError::InvalidArgument`] — deterministic, at propose time, never
17147/// post-commit. (Taxonomy: `InvalidArgument` maps to
17148/// `ErrorCategory::ClusterVersionMismatch`, a request/binary contract
17149/// disagreement that is never retried unchanged. The normative spec category
17150/// for "the commit was not applied; only a fresh transaction may succeed" is
17151/// `CommitTooLate`; surfacing it needs a dedicated `MongrelError` variant in
17152/// `error.rs`, which is outside this change's file scope — tracked as a
17153/// follow-up.)
17154pub fn translate_records_for_replication(
17155    records: &[crate::wal::Record],
17156) -> Result<Vec<crate::wal::Record>> {
17157    use crate::wal::Op;
17158
17159    // Structural validation mirrors `apply_replicated_records`: one
17160    // transaction, exactly one commit marker, at the tail.
17161    let txn_id = records.first().map(|record| record.txn_id).ok_or_else(|| {
17162        MongrelError::InvalidArgument("replicated transaction payload is empty".into())
17163    })?;
17164    if records.iter().any(|record| record.txn_id != txn_id) {
17165        return Err(MongrelError::InvalidArgument(
17166            "replicated transaction payload mixes transaction ids".into(),
17167        ));
17168    }
17169    let commits = records
17170        .iter()
17171        .filter(|record| matches!(record.op, Op::TxnCommit { .. }))
17172        .count();
17173    if commits != 1 || !matches!(records.last().map(|r| &r.op), Some(Op::TxnCommit { .. })) {
17174        return Err(MongrelError::InvalidArgument(
17175            "replicated transaction payload must end in exactly one commit marker".into(),
17176        ));
17177    }
17178
17179    // Decode every logical spill payload now: a payload that cannot decode
17180    // here would fail every replica's apply identically — reject at propose
17181    // time instead.
17182    let mut spilled_rows: HashMap<u64, Vec<crate::memtable::Row>> = HashMap::new();
17183    for record in records {
17184        if let Op::SpilledRows { table_id, rows } = &record.op {
17185            let chunk: Vec<crate::memtable::Row> = bincode::deserialize(rows).map_err(|error| {
17186                MongrelError::InvalidArgument(format!(
17187                    "spilled row payload for table {table_id} cannot decode for replication: \
17188                         {error}"
17189                ))
17190            })?;
17191            spilled_rows.entry(*table_id).or_default().extend(chunk);
17192        }
17193    }
17194
17195    // Coverage proof: every run the commit marker links must have its full
17196    // row content present as logical records, or replicas would silently
17197    // lose those rows.
17198    let Some(Op::TxnCommit { added_runs, .. }) = records.last().map(|r| &r.op) else {
17199        unreachable!("one trailing commit marker validated above");
17200    };
17201    for run in added_runs {
17202        let rows = spilled_rows.get(&run.table_id).ok_or_else(|| {
17203            MongrelError::InvalidArgument(format!(
17204                "commit links spilled run {} for table {} but carries no logical row records \
17205                 for it",
17206                run.run_id, run.table_id
17207            ))
17208        })?;
17209        let covered = rows
17210            .iter()
17211            .filter(|row| row.row_id.0 >= run.min_row_id && row.row_id.0 <= run.max_row_id)
17212            .count() as u64;
17213        if covered != run.row_count {
17214            return Err(MongrelError::InvalidArgument(format!(
17215                "commit links spilled run {} for table {} ({} rows in [{}, {}]) but the logical \
17216                 row records cover {} rows",
17217                run.run_id, run.table_id, run.row_count, run.min_row_id, run.max_row_id, covered
17218            )));
17219        }
17220    }
17221
17222    // Translate: spill payloads become ordinary puts; the commit marker no
17223    // longer references leader-local run files.
17224    let translated = records
17225        .iter()
17226        .map(|record| {
17227            let op = match &record.op {
17228                Op::SpilledRows { table_id, rows } => Op::Put {
17229                    table_id: *table_id,
17230                    rows: rows.clone(),
17231                },
17232                Op::TxnCommit { epoch, .. } => Op::TxnCommit {
17233                    epoch: *epoch,
17234                    added_runs: Vec::new(),
17235                },
17236                op => op.clone(),
17237            };
17238            crate::wal::Record::new(record.seq, record.txn_id, op)
17239        })
17240        .collect();
17241    Ok(translated)
17242}
17243
17244fn recover_shared_wal(
17245    durable_root: &crate::durable_file::DurableRoot,
17246    tables: &HashMap<u64, TableHandle>,
17247    catalog: &Catalog,
17248    epoch: &EpochAuthority,
17249    records: &[crate::wal::Record],
17250) -> Result<()> {
17251    use crate::memtable::Row;
17252    use crate::wal::{DdlOp, Op};
17253
17254    // Pass 1: committed-txn outcomes + collect spilled-run info.
17255    let mut committed: HashMap<u64, u64> = HashMap::new();
17256    // Physical HLC micros from Op::CommitTimestamp, keyed by txn_id (P0.5).
17257    let mut commit_ts_by_txn: HashMap<u64, mongreldb_types::hlc::HlcTimestamp> = HashMap::new();
17258    let mut spilled_to_link: Vec<(
17259        u64, /*txn_id*/
17260        u64, /*epoch*/
17261        Vec<crate::wal::AddedRun>,
17262    )> = Vec::new();
17263    for r in records {
17264        match &r.op {
17265            Op::CommitTimestamp { unix_nanos } => {
17266                commit_ts_by_txn.insert(
17267                    r.txn_id,
17268                    mongreldb_types::hlc::HlcTimestamp {
17269                        physical_micros: unix_nanos / 1_000,
17270                        logical: 0,
17271                        node_tiebreaker: 0,
17272                    },
17273                );
17274            }
17275            Op::TxnCommit {
17276                epoch: ce,
17277                ref added_runs,
17278            } => {
17279                committed.insert(r.txn_id, *ce);
17280                if !added_runs.is_empty() {
17281                    spilled_to_link.push((r.txn_id, *ce, added_runs.clone()));
17282                }
17283            }
17284            _ => {}
17285        }
17286    }
17287    for record in records {
17288        let Some(&commit_epoch) = committed.get(&record.txn_id) else {
17289            continue;
17290        };
17291        match &record.op {
17292            Op::Put { table_id, .. }
17293            | Op::Delete { table_id, .. }
17294            | Op::TruncateTable { table_id } => {
17295                validate_recovered_data_table(
17296                    catalog,
17297                    tables,
17298                    *table_id,
17299                    commit_epoch,
17300                    record.seq.0,
17301                )?;
17302            }
17303            Op::TxnCommit { added_runs, .. } => {
17304                for run in added_runs {
17305                    validate_recovered_data_table(
17306                        catalog,
17307                        tables,
17308                        run.table_id,
17309                        commit_epoch,
17310                        record.seq.0,
17311                    )?;
17312                }
17313            }
17314            _ => {}
17315        }
17316    }
17317    let truncated_transactions: HashSet<(u64, u64)> = records
17318        .iter()
17319        .filter_map(|record| {
17320            committed.get(&record.txn_id)?;
17321            match record.op {
17322                Op::TruncateTable { table_id } => Some((record.txn_id, table_id)),
17323                _ => None,
17324            }
17325        })
17326        .collect();
17327
17328    // Pass 2: stage data per table, gated by flushed_epoch.
17329    enum ExternalRecoveryAction {
17330        Write { name: String, state: Vec<u8> },
17331        Reset { name: String },
17332    }
17333    let mut stage: HashMap<u64, RecoveryTableStage> = HashMap::new();
17334    let mut external_actions = Vec::new();
17335    let mut max_epoch = epoch.visible().0;
17336    for r in records.iter().cloned() {
17337        let Some(&ce) = committed.get(&r.txn_id) else {
17338            continue; // aborted / in-flight — discard
17339        };
17340        let commit_epoch = Epoch(ce);
17341        max_epoch = max_epoch.max(ce);
17342        match r.op {
17343            Op::Put { table_id, rows } => {
17344                // Skip if this table already flushed past the commit epoch.
17345                let skip = tables
17346                    .get(&table_id)
17347                    .map(|h| h.lock().flushed_epoch() >= ce)
17348                    .unwrap_or(true);
17349                if skip {
17350                    continue;
17351                }
17352                let rows: Vec<Row> = bincode::deserialize(&rows).map_err(|error| {
17353                    MongrelError::CorruptWal {
17354                        offset: r.seq.0,
17355                        reason: format!(
17356                            "committed Put payload for transaction {} could not be decoded: {error}",
17357                            r.txn_id
17358                        ),
17359                    }
17360                })?;
17361                // Re-stamp each row at the txn commit epoch (rows are pre-stamped
17362                // at pending_epoch which equals the commit epoch, but be robust).
17363                // P0.5: prefer the durable CommitTimestamp HLC for this txn when
17364                // present so recovery restores HLC-authoritative versions.
17365                let txn_commit_ts = commit_ts_by_txn.get(&r.txn_id).copied();
17366                let rows: Vec<Row> = rows
17367                    .into_iter()
17368                    .map(|mut row| {
17369                        row.committed_epoch = commit_epoch;
17370                        if row.commit_ts.is_none() {
17371                            row.commit_ts = txn_commit_ts;
17372                        }
17373                        row
17374                    })
17375                    .collect();
17376                let entry = stage
17377                    .entry(table_id)
17378                    .or_insert_with(|| (Vec::new(), Vec::new(), None, commit_epoch));
17379                entry.0.extend(rows);
17380                entry.3 = commit_epoch;
17381            }
17382            Op::Delete { table_id, row_ids } => {
17383                let skip = tables
17384                    .get(&table_id)
17385                    .map(|h| h.lock().flushed_epoch() >= ce)
17386                    .unwrap_or(true);
17387                if skip {
17388                    continue;
17389                }
17390                let dels = row_ids.into_iter().map(|rid| (rid, commit_epoch));
17391                let entry = stage
17392                    .entry(table_id)
17393                    .or_insert_with(|| (Vec::new(), Vec::new(), None, commit_epoch));
17394                entry.1.extend(dels);
17395                entry.3 = commit_epoch;
17396            }
17397            Op::TruncateTable { table_id } => {
17398                let skip = tables
17399                    .get(&table_id)
17400                    .map(|h| h.lock().flushed_epoch() >= ce)
17401                    .unwrap_or(true);
17402                if skip {
17403                    continue;
17404                }
17405                stage.insert(
17406                    table_id,
17407                    (Vec::new(), Vec::new(), Some(commit_epoch), commit_epoch),
17408                );
17409            }
17410            Op::ExternalTableState { name, state } => {
17411                let current_generation = catalog
17412                    .external_tables
17413                    .iter()
17414                    .find(|entry| entry.name == name)
17415                    .map(|entry| entry.created_epoch);
17416                if current_generation.is_some_and(|created_epoch| ce >= created_epoch) {
17417                    validate_recovered_external_name(&name)?;
17418                    external_actions.push(ExternalRecoveryAction::Write { name, state });
17419                }
17420            }
17421            Op::Ddl(DdlOp::ResetExternalTableState {
17422                name,
17423                generation_epoch,
17424            }) => {
17425                if generation_epoch != ce {
17426                    return Err(MongrelError::CorruptWal {
17427                        offset: r.seq.0,
17428                        reason: format!(
17429                        "external state reset epoch {generation_epoch} does not match WAL commit epoch {ce}"
17430                    ),
17431                    });
17432                }
17433                validate_recovered_external_name(&name)?;
17434                external_actions.push(ExternalRecoveryAction::Reset { name });
17435            }
17436            Op::Flush { .. }
17437            | Op::TxnCommit { .. }
17438            | Op::TxnAbort
17439            | Op::Ddl(_)
17440            | Op::BeforeImage { .. }
17441            | Op::CommitTimestamp { .. }
17442            | Op::SpilledRows { .. } => {}
17443        }
17444    }
17445    for (_, commit_epoch, added_runs) in &mut spilled_to_link {
17446        added_runs.retain(|added| {
17447            tables
17448                .get(&added.table_id)
17449                .is_some_and(|table| table.lock().flushed_epoch() < *commit_epoch)
17450        });
17451    }
17452    spilled_to_link.retain(|(_, _, added_runs)| !added_runs.is_empty());
17453    validate_recovery_table_stages(tables, &stage)?;
17454    validate_recovery_spilled_runs(durable_root, tables, &spilled_to_link)?;
17455
17456    // All WAL payloads, catalog generations, table stages, and immutable run
17457    // identities have now been validated. Only this application phase mutates
17458    // the database tree.
17459    for action in external_actions {
17460        match action {
17461            ExternalRecoveryAction::Write { name, state } => {
17462                write_external_state_file(durable_root, &name, &state)?;
17463            }
17464            ExternalRecoveryAction::Reset { name } => {
17465                durable_root.create_directory_all(VTAB_DIR)?;
17466                durable_root.remove_directory_all(Path::new(VTAB_DIR).join(name))?;
17467            }
17468        }
17469    }
17470    for (table_id, (rows, deletes, truncate_epoch, table_epoch)) in stage {
17471        let Some(handle) = tables.get(&table_id) else {
17472            continue;
17473        };
17474        let mut t = handle.lock();
17475        if let Some(epoch) = truncate_epoch {
17476            t.apply_truncate(epoch);
17477        }
17478        t.recover_apply(rows, deletes)?;
17479        // The WAL can be newer than the copied/persisted manifest after a
17480        // crash or replication apply. Rebuild O(1) count metadata from the
17481        // recovered state before endorsing the commit epoch in the manifest.
17482        let rows = t.visible_rows(Snapshot::unbounded())?;
17483        t.live_count = rows.len() as u64;
17484        // Recovery can replay older row commits while a newer spilled run is
17485        // already linked by the copied manifest. Never move that manifest's
17486        // epoch behind its existing run references.
17487        t.persist_manifest(table_epoch.max(epoch.visible()))?;
17488    }
17489
17490    // Pass 3: link spilled runs from committed txns (spec §8.5). A crash
17491    // between TxnCommit sync and the publish phase leaves the run in
17492    // `_txn/<txn_id>/`. Move it to `_runs/` and add the RunRef.
17493    for (txn_id, ce, added_runs) in &spilled_to_link {
17494        for ar in added_runs {
17495            let Some(handle) = tables.get(&ar.table_id) else {
17496                continue;
17497            };
17498            let mut t = handle.lock();
17499            let table_dir = Path::new(TABLES_DIR).join(ar.table_id.to_string());
17500            let destination = table_dir
17501                .join(crate::engine::RUNS_DIR)
17502                .join(format!("r-{}.sr", ar.run_id));
17503            match durable_root.open_regular(&destination) {
17504                Ok(_) => {}
17505                Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
17506                    let pending = table_dir
17507                        .join("_txn")
17508                        .join(txn_id.to_string())
17509                        .join(format!("r-{}.sr", ar.run_id));
17510                    durable_root.rename_file_new(&pending, &destination)?;
17511                }
17512                Err(error) => return Err(error.into()),
17513            }
17514            // Only link a run whose file is actually present, and never re-link
17515            // one the publish phase already persisted into the manifest (which is
17516            // the common clean-reopen case, since the `TxnCommit` lives in the WAL
17517            // until segment GC). `recover_spilled_run` is idempotent + reconciles
17518            // `live_count`/indexes only when the run is genuinely new.
17519            let linked = t.recover_spilled_run(crate::manifest::RunRef {
17520                run_id: ar.run_id,
17521                level: ar.level,
17522                epoch_created: *ce,
17523                row_count: ar.row_count,
17524            });
17525            let replaced = truncated_transactions.contains(&(*txn_id, ar.table_id));
17526            if replaced {
17527                t.set_flushed_epoch(Epoch(*ce));
17528            }
17529            if linked || replaced {
17530                t.persist_manifest(Epoch(*ce).max(epoch.visible()))?;
17531            }
17532        }
17533    }
17534
17535    epoch.advance_recovered(Epoch(max_epoch));
17536    Ok(())
17537}
17538
17539fn reconcile_recovered_table_metadata(
17540    tables: &HashMap<u64, TableHandle>,
17541    epoch: Epoch,
17542) -> Result<()> {
17543    let mut table_ids = tables.keys().copied().collect::<Vec<_>>();
17544    table_ids.sort_unstable();
17545    let mut plans = Vec::with_capacity(table_ids.len());
17546    for table_id in &table_ids {
17547        let handle = tables.get(table_id).ok_or_else(|| {
17548            MongrelError::Other(format!("mounted table {table_id} vanished during recovery"))
17549        })?;
17550        plans.push((*table_id, handle.lock().plan_recovered_metadata()?));
17551    }
17552    // Every table's data and metadata have been decoded successfully. Publish
17553    // repairs only after the complete database-wide plan is known valid.
17554    for (table_id, plan) in plans {
17555        let handle = tables.get(&table_id).ok_or_else(|| {
17556            MongrelError::Other(format!("mounted table {table_id} vanished during recovery"))
17557        })?;
17558        handle.lock().apply_recovered_metadata(plan, epoch)?;
17559    }
17560    Ok(())
17561}
17562
17563fn validate_recovered_external_name(name: &str) -> Result<()> {
17564    if name.is_empty()
17565        || !name.chars().all(|character| {
17566            character.is_ascii_alphanumeric() || character == '_' || character == '-'
17567        })
17568    {
17569        return Err(MongrelError::CorruptWal {
17570            offset: 0,
17571            reason: format!("unsafe recovered external-table name {name:?}"),
17572        });
17573    }
17574    Ok(())
17575}
17576
17577fn validate_recovery_table_stages(
17578    tables: &HashMap<u64, TableHandle>,
17579    stages: &HashMap<u64, RecoveryTableStage>,
17580) -> Result<()> {
17581    for (table_id, (rows, _, _, _)) in stages {
17582        let handle = tables
17583            .get(table_id)
17584            .ok_or_else(|| MongrelError::CorruptWal {
17585                offset: *table_id,
17586                reason: format!("recovery stage references unmounted table {table_id}"),
17587            })?;
17588        let table = handle.lock();
17589        // Force all existing immutable runs through their integrity/decode path
17590        // before any other table manifest can be changed.
17591        table.visible_rows(Snapshot::unbounded())?;
17592        for row in rows {
17593            validate_recovered_row(table.schema(), row)?;
17594        }
17595    }
17596    Ok(())
17597}
17598
17599fn validate_recovered_row(schema: &Schema, row: &crate::memtable::Row) -> Result<()> {
17600    if row.deleted || row.row_id.0 == u64::MAX {
17601        return Err(MongrelError::CorruptWal {
17602            offset: row.row_id.0,
17603            reason: "committed Put payload contains a tombstone or exhausted row id".into(),
17604        });
17605    }
17606    let cells = row
17607        .columns
17608        .iter()
17609        .map(|(column, value)| (*column, value.clone()))
17610        .collect::<Vec<_>>();
17611    schema
17612        .validate_persisted_values(&cells)
17613        .map_err(|error| MongrelError::CorruptWal {
17614            offset: row.row_id.0,
17615            reason: format!("recovered row violates table schema: {error}"),
17616        })?;
17617    if schema.auto_increment_column().is_some_and(|column| {
17618        matches!(row.columns.get(&column.id), Some(Value::Int64(value)) if *value == i64::MAX)
17619    }) {
17620        return Err(MongrelError::CorruptWal {
17621            offset: row.row_id.0,
17622            reason: "recovered AUTO_INCREMENT value exhausts i64".into(),
17623        });
17624    }
17625    Ok(())
17626}
17627
17628fn validate_recovery_spilled_runs(
17629    root: &crate::durable_file::DurableRoot,
17630    tables: &HashMap<u64, TableHandle>,
17631    spilled: &[(u64, u64, Vec<crate::wal::AddedRun>)],
17632) -> Result<()> {
17633    let mut identities = HashSet::new();
17634    for (txn_id, commit_epoch, added_runs) in spilled {
17635        for added in added_runs {
17636            if added.run_id >= u64::MAX as u128 {
17637                return Err(MongrelError::CorruptWal {
17638                    offset: *commit_epoch,
17639                    reason: format!(
17640                        "recovered run id {} exceeds the on-disk namespace",
17641                        added.run_id
17642                    ),
17643                });
17644            }
17645            let Some(handle) = tables.get(&added.table_id) else {
17646                continue;
17647            };
17648            if !identities.insert((added.table_id, added.run_id)) {
17649                return Err(MongrelError::CorruptWal {
17650                    offset: *commit_epoch,
17651                    reason: format!(
17652                        "duplicate recovered run {} for table {}",
17653                        added.run_id, added.table_id
17654                    ),
17655                });
17656            }
17657            let table = handle.lock();
17658            validate_planned_spilled_run(
17659                root,
17660                *txn_id,
17661                *commit_epoch,
17662                added,
17663                table.schema(),
17664                table.kek(),
17665            )?;
17666        }
17667    }
17668    Ok(())
17669}
17670
17671fn validate_condition_columns(condition: &ProcedureCondition, schema: &Schema) -> Result<()> {
17672    match condition {
17673        ProcedureCondition::Pk { .. } => {
17674            if schema.primary_key().is_none() {
17675                return Err(MongrelError::InvalidArgument(
17676                    "procedure condition Pk references a table without a primary key".into(),
17677                ));
17678            }
17679        }
17680        ProcedureCondition::BitmapEq { column_id, .. }
17681        | ProcedureCondition::BitmapIn { column_id, .. }
17682        | ProcedureCondition::Range { column_id, .. }
17683        | ProcedureCondition::RangeF64 { column_id, .. }
17684        | ProcedureCondition::IsNull { column_id }
17685        | ProcedureCondition::IsNotNull { column_id }
17686        | ProcedureCondition::FmContains { column_id, .. } => {
17687            validate_column_id(*column_id, schema)?;
17688        }
17689    }
17690    Ok(())
17691}
17692
17693fn bind_procedure_args(
17694    procedure: &StoredProcedure,
17695    mut args: HashMap<String, crate::Value>,
17696) -> Result<HashMap<String, crate::Value>> {
17697    let mut out = HashMap::new();
17698    for param in &procedure.params {
17699        let value = match args.remove(&param.name) {
17700            Some(value) => value,
17701            None => param.default.clone().ok_or_else(|| {
17702                MongrelError::InvalidArgument(format!(
17703                    "missing required procedure parameter {:?}",
17704                    param.name
17705                ))
17706            })?,
17707        };
17708        if !param.nullable && matches!(value, crate::Value::Null) {
17709            return Err(MongrelError::InvalidArgument(format!(
17710                "procedure parameter {:?} must not be NULL",
17711                param.name
17712            )));
17713        }
17714        if !matches!(value, crate::Value::Null) && !value_matches_type(&value, param.ty.clone()) {
17715            return Err(MongrelError::InvalidArgument(format!(
17716                "procedure parameter {:?} has wrong type",
17717                param.name
17718            )));
17719        }
17720        out.insert(param.name.clone(), value);
17721    }
17722    if let Some(extra) = args.keys().next() {
17723        return Err(MongrelError::InvalidArgument(format!(
17724            "unknown procedure parameter {extra:?}"
17725        )));
17726    }
17727    Ok(out)
17728}
17729
17730fn value_matches_type(value: &crate::Value, ty: crate::TypeId) -> bool {
17731    matches!(
17732        (value, ty),
17733        (crate::Value::Bool(_), crate::TypeId::Bool)
17734            | (crate::Value::Int64(_), crate::TypeId::Int8)
17735            | (crate::Value::Int64(_), crate::TypeId::Int16)
17736            | (crate::Value::Int64(_), crate::TypeId::Int32)
17737            | (crate::Value::Int64(_), crate::TypeId::Int64)
17738            | (crate::Value::Int64(_), crate::TypeId::UInt8)
17739            | (crate::Value::Int64(_), crate::TypeId::UInt16)
17740            | (crate::Value::Int64(_), crate::TypeId::UInt32)
17741            | (crate::Value::Int64(_), crate::TypeId::UInt64)
17742            | (crate::Value::Int64(_), crate::TypeId::TimestampNanos)
17743            | (crate::Value::Int64(_), crate::TypeId::Date32)
17744            | (crate::Value::Float64(_), crate::TypeId::Float32)
17745            | (crate::Value::Float64(_), crate::TypeId::Float64)
17746            | (crate::Value::Bytes(_), crate::TypeId::Bytes)
17747            | (crate::Value::Embedding(_), crate::TypeId::Embedding { .. })
17748            | (
17749                crate::Value::GeneratedEmbedding(_),
17750                crate::TypeId::Embedding { .. }
17751            )
17752    )
17753}
17754
17755fn eval_cells(
17756    cells: &[crate::procedure::ProcedureCell],
17757    args: &HashMap<String, crate::Value>,
17758    outputs: &HashMap<String, ProcedureCallOutput>,
17759) -> Result<Vec<(u16, crate::Value)>> {
17760    cells
17761        .iter()
17762        .map(|cell| Ok((cell.column_id, eval_value(&cell.value, args, outputs)?)))
17763        .collect()
17764}
17765
17766fn eval_condition(
17767    condition: &ProcedureCondition,
17768    args: &HashMap<String, crate::Value>,
17769    outputs: &HashMap<String, ProcedureCallOutput>,
17770) -> Result<crate::Condition> {
17771    Ok(match condition {
17772        ProcedureCondition::Pk { value } => {
17773            crate::Condition::Pk(eval_value(value, args, outputs)?.encode_key())
17774        }
17775        ProcedureCondition::BitmapEq { column_id, value } => crate::Condition::BitmapEq {
17776            column_id: *column_id,
17777            value: eval_value(value, args, outputs)?.encode_key(),
17778        },
17779        ProcedureCondition::BitmapIn { column_id, values } => crate::Condition::BitmapIn {
17780            column_id: *column_id,
17781            values: values
17782                .iter()
17783                .map(|value| Ok(eval_value(value, args, outputs)?.encode_key()))
17784                .collect::<Result<Vec<_>>>()?,
17785        },
17786        ProcedureCondition::Range { column_id, lo, hi } => crate::Condition::Range {
17787            column_id: *column_id,
17788            lo: expect_i64(eval_value(lo, args, outputs)?)?,
17789            hi: expect_i64(eval_value(hi, args, outputs)?)?,
17790        },
17791        ProcedureCondition::RangeF64 {
17792            column_id,
17793            lo,
17794            lo_inclusive,
17795            hi,
17796            hi_inclusive,
17797        } => crate::Condition::RangeF64 {
17798            column_id: *column_id,
17799            lo: expect_f64(eval_value(lo, args, outputs)?)?,
17800            lo_inclusive: *lo_inclusive,
17801            hi: expect_f64(eval_value(hi, args, outputs)?)?,
17802            hi_inclusive: *hi_inclusive,
17803        },
17804        ProcedureCondition::IsNull { column_id } => crate::Condition::IsNull {
17805            column_id: *column_id,
17806        },
17807        ProcedureCondition::IsNotNull { column_id } => crate::Condition::IsNotNull {
17808            column_id: *column_id,
17809        },
17810        ProcedureCondition::FmContains { column_id, pattern } => crate::Condition::FmContains {
17811            column_id: *column_id,
17812            pattern: expect_bytes(eval_value(pattern, args, outputs)?)?,
17813        },
17814    })
17815}
17816
17817fn eval_value(
17818    value: &ProcedureValue,
17819    args: &HashMap<String, crate::Value>,
17820    outputs: &HashMap<String, ProcedureCallOutput>,
17821) -> Result<crate::Value> {
17822    match value {
17823        ProcedureValue::Literal(value) => Ok(value.clone()),
17824        ProcedureValue::Param(name) => args.get(name).cloned().ok_or_else(|| {
17825            MongrelError::InvalidArgument(format!("unknown procedure parameter {name:?}"))
17826        }),
17827        ProcedureValue::StepScalar(id) => match outputs.get(id) {
17828            Some(ProcedureCallOutput::Scalar(value)) => Ok(value.clone()),
17829            _ => Err(MongrelError::InvalidArgument(format!(
17830                "procedure step {id:?} did not return a scalar"
17831            ))),
17832        },
17833        ProcedureValue::StepRows(_) | ProcedureValue::StepRow(_) => {
17834            Err(MongrelError::InvalidArgument(
17835                "row-valued procedure reference cannot be used as a scalar".into(),
17836            ))
17837        }
17838        ProcedureValue::Object(_) | ProcedureValue::Array(_) => Err(MongrelError::InvalidArgument(
17839            "structured procedure value cannot be used as a scalar cell".into(),
17840        )),
17841    }
17842}
17843
17844fn eval_return_output(
17845    value: &ProcedureValue,
17846    args: &HashMap<String, crate::Value>,
17847    outputs: &HashMap<String, ProcedureCallOutput>,
17848) -> Result<ProcedureCallOutput> {
17849    match value {
17850        ProcedureValue::Literal(value) => Ok(ProcedureCallOutput::Scalar(value.clone())),
17851        ProcedureValue::Param(name) => Ok(ProcedureCallOutput::Scalar(
17852            args.get(name).cloned().ok_or_else(|| {
17853                MongrelError::InvalidArgument(format!("unknown procedure parameter {name:?}"))
17854            })?,
17855        )),
17856        ProcedureValue::StepRows(id)
17857        | ProcedureValue::StepRow(id)
17858        | ProcedureValue::StepScalar(id) => outputs.get(id).cloned().ok_or_else(|| {
17859            MongrelError::InvalidArgument(format!("unknown procedure step output {id:?}"))
17860        }),
17861        ProcedureValue::Object(fields) => {
17862            let mut out = Vec::with_capacity(fields.len());
17863            for (name, value) in fields {
17864                out.push((name.clone(), eval_return_output(value, args, outputs)?));
17865            }
17866            Ok(ProcedureCallOutput::Object(out))
17867        }
17868        ProcedureValue::Array(values) => {
17869            let mut out = Vec::with_capacity(values.len());
17870            for value in values {
17871                out.push(eval_return_output(value, args, outputs)?);
17872            }
17873            Ok(ProcedureCallOutput::Array(out))
17874        }
17875    }
17876}
17877
17878fn expect_i64(value: crate::Value) -> Result<i64> {
17879    match value {
17880        crate::Value::Int64(value) => Ok(value),
17881        _ => Err(MongrelError::InvalidArgument(
17882            "procedure value must be Int64".into(),
17883        )),
17884    }
17885}
17886
17887fn expect_f64(value: crate::Value) -> Result<f64> {
17888    match value {
17889        crate::Value::Float64(value) => Ok(value),
17890        _ => Err(MongrelError::InvalidArgument(
17891            "procedure value must be Float64".into(),
17892        )),
17893    }
17894}
17895
17896fn expect_bytes(value: crate::Value) -> Result<Vec<u8>> {
17897    match value {
17898        crate::Value::Bytes(value) => Ok(value),
17899        _ => Err(MongrelError::InvalidArgument(
17900            "procedure value must be Bytes".into(),
17901        )),
17902    }
17903}
17904
17905fn validate_column_id(column_id: u16, schema: &Schema) -> Result<()> {
17906    if schema.columns.iter().any(|c| c.id == column_id) {
17907        Ok(())
17908    } else {
17909        Err(MongrelError::InvalidArgument(format!(
17910            "unknown column id {column_id}"
17911        )))
17912    }
17913}
17914
17915fn trigger_matches_event(
17916    trigger: &StoredTrigger,
17917    event: &WriteEvent,
17918    cat: &Catalog,
17919) -> Result<bool> {
17920    if trigger.event != event.kind {
17921        return Ok(false);
17922    }
17923    let TriggerTarget::Table(target) = &trigger.target else {
17924        return Ok(false);
17925    };
17926    if target != &event.table {
17927        return Ok(false);
17928    }
17929    if trigger.event == TriggerEvent::Update && !trigger.update_of.is_empty() {
17930        let schema = &cat
17931            .live(target)
17932            .ok_or_else(|| {
17933                MongrelError::InvalidArgument(format!(
17934                    "trigger {:?} references unknown table {target:?}",
17935                    trigger.name
17936                ))
17937            })?
17938            .schema;
17939        let mut watched = Vec::with_capacity(trigger.update_of.len());
17940        for name in &trigger.update_of {
17941            let col = schema.column(name).ok_or_else(|| {
17942                MongrelError::InvalidArgument(format!(
17943                    "trigger {:?} references unknown UPDATE OF column {name:?}",
17944                    trigger.name
17945                ))
17946            })?;
17947            watched.push(col.id);
17948        }
17949        if !event
17950            .changed_columns
17951            .iter()
17952            .any(|column_id| watched.contains(column_id))
17953        {
17954            return Ok(false);
17955        }
17956    }
17957    Ok(true)
17958}
17959
17960fn changed_columns(old: Option<&TriggerRowImage>, new: Option<&TriggerRowImage>) -> Vec<u16> {
17961    let mut ids = std::collections::BTreeSet::new();
17962    if let Some(old) = old {
17963        ids.extend(old.columns.keys().copied());
17964    }
17965    if let Some(new) = new {
17966        ids.extend(new.columns.keys().copied());
17967    }
17968    ids.into_iter()
17969        .filter(|id| {
17970            old.and_then(|row| row.columns.get(id)) != new.and_then(|row| row.columns.get(id))
17971        })
17972        .collect()
17973}
17974
17975fn eval_trigger_cells(
17976    cells: &[crate::trigger::TriggerCell],
17977    event: &WriteEvent,
17978    selected: Option<&TriggerRowImage>,
17979) -> Result<Vec<(u16, Value)>> {
17980    cells
17981        .iter()
17982        .map(|cell| {
17983            Ok((
17984                cell.column_id,
17985                eval_trigger_value(&cell.value, event, selected)?,
17986            ))
17987        })
17988        .collect()
17989}
17990
17991fn eval_trigger_expr(expr: &TriggerExpr, event: &WriteEvent) -> Result<bool> {
17992    match expr {
17993        TriggerExpr::Value(value) => match eval_trigger_value(value, event, None)? {
17994            Value::Bool(value) => Ok(value),
17995            Value::Null => Ok(false),
17996            other => Err(MongrelError::InvalidArgument(format!(
17997                "trigger WHEN value must be boolean, got {other:?}"
17998            ))),
17999        },
18000        TriggerExpr::Eq { left, right } => Ok(values_equal(
18001            &eval_trigger_value(left, event, None)?,
18002            &eval_trigger_value(right, event, None)?,
18003        )),
18004        TriggerExpr::NotEq { left, right } => Ok(!values_equal(
18005            &eval_trigger_value(left, event, None)?,
18006            &eval_trigger_value(right, event, None)?,
18007        )),
18008        TriggerExpr::Lt { left, right } => match value_order(
18009            &eval_trigger_value(left, event, None)?,
18010            &eval_trigger_value(right, event, None)?,
18011        ) {
18012            Some(ordering) => Ok(ordering == std::cmp::Ordering::Less),
18013            None => Ok(false),
18014        },
18015        TriggerExpr::Lte { left, right } => match value_order(
18016            &eval_trigger_value(left, event, None)?,
18017            &eval_trigger_value(right, event, None)?,
18018        ) {
18019            Some(ordering) => Ok(ordering != std::cmp::Ordering::Greater),
18020            None => Ok(false),
18021        },
18022        TriggerExpr::Gt { left, right } => match value_order(
18023            &eval_trigger_value(left, event, None)?,
18024            &eval_trigger_value(right, event, None)?,
18025        ) {
18026            Some(ordering) => Ok(ordering == std::cmp::Ordering::Greater),
18027            None => Ok(false),
18028        },
18029        TriggerExpr::Gte { left, right } => match value_order(
18030            &eval_trigger_value(left, event, None)?,
18031            &eval_trigger_value(right, event, None)?,
18032        ) {
18033            Some(ordering) => Ok(ordering != std::cmp::Ordering::Less),
18034            None => Ok(false),
18035        },
18036        TriggerExpr::IsNull(value) => Ok(matches!(
18037            eval_trigger_value(value, event, None)?,
18038            Value::Null
18039        )),
18040        TriggerExpr::IsNotNull(value) => Ok(!matches!(
18041            eval_trigger_value(value, event, None)?,
18042            Value::Null
18043        )),
18044        TriggerExpr::And { left, right } => {
18045            if !eval_trigger_expr(left, event)? {
18046                Ok(false)
18047            } else {
18048                Ok(eval_trigger_expr(right, event)?)
18049            }
18050        }
18051        TriggerExpr::Or { left, right } => {
18052            if eval_trigger_expr(left, event)? {
18053                Ok(true)
18054            } else {
18055                Ok(eval_trigger_expr(right, event)?)
18056            }
18057        }
18058        TriggerExpr::Not(expr) => Ok(!eval_trigger_expr(expr, event)?),
18059    }
18060}
18061
18062fn eval_trigger_condition(
18063    condition: &TriggerCondition,
18064    event: &WriteEvent,
18065    selected: &TriggerRowImage,
18066    schema: &Schema,
18067) -> Result<bool> {
18068    match condition {
18069        TriggerCondition::Pk { value } => {
18070            let pk = schema.primary_key().ok_or_else(|| {
18071                MongrelError::InvalidArgument(
18072                    "trigger condition Pk references a table without a primary key".into(),
18073                )
18074            })?;
18075            let lhs = eval_trigger_value(value, event, Some(selected))?;
18076            Ok(values_equal(
18077                &lhs,
18078                selected.columns.get(&pk.id).unwrap_or(&Value::Null),
18079            ))
18080        }
18081        TriggerCondition::Eq { column_id, value } => Ok(values_equal(
18082            selected.columns.get(column_id).unwrap_or(&Value::Null),
18083            &eval_trigger_value(value, event, Some(selected))?,
18084        )),
18085        TriggerCondition::NotEq { column_id, value } => Ok(!values_equal(
18086            selected.columns.get(column_id).unwrap_or(&Value::Null),
18087            &eval_trigger_value(value, event, Some(selected))?,
18088        )),
18089        TriggerCondition::Lt { column_id, value } => match value_order(
18090            selected.columns.get(column_id).unwrap_or(&Value::Null),
18091            &eval_trigger_value(value, event, Some(selected))?,
18092        ) {
18093            Some(ordering) => Ok(ordering == std::cmp::Ordering::Less),
18094            None => Ok(false),
18095        },
18096        TriggerCondition::Lte { column_id, value } => match value_order(
18097            selected.columns.get(column_id).unwrap_or(&Value::Null),
18098            &eval_trigger_value(value, event, Some(selected))?,
18099        ) {
18100            Some(ordering) => Ok(ordering != std::cmp::Ordering::Greater),
18101            None => Ok(false),
18102        },
18103        TriggerCondition::Gt { column_id, value } => match value_order(
18104            selected.columns.get(column_id).unwrap_or(&Value::Null),
18105            &eval_trigger_value(value, event, Some(selected))?,
18106        ) {
18107            Some(ordering) => Ok(ordering == std::cmp::Ordering::Greater),
18108            None => Ok(false),
18109        },
18110        TriggerCondition::Gte { column_id, value } => match value_order(
18111            selected.columns.get(column_id).unwrap_or(&Value::Null),
18112            &eval_trigger_value(value, event, Some(selected))?,
18113        ) {
18114            Some(ordering) => Ok(ordering != std::cmp::Ordering::Less),
18115            None => Ok(false),
18116        },
18117        TriggerCondition::IsNull { column_id } => Ok(matches!(
18118            selected.columns.get(column_id),
18119            None | Some(Value::Null)
18120        )),
18121        TriggerCondition::IsNotNull { column_id } => Ok(!matches!(
18122            selected.columns.get(column_id),
18123            None | Some(Value::Null)
18124        )),
18125        TriggerCondition::And { left, right } => {
18126            if !eval_trigger_condition(left, event, selected, schema)? {
18127                Ok(false)
18128            } else {
18129                Ok(eval_trigger_condition(right, event, selected, schema)?)
18130            }
18131        }
18132        TriggerCondition::Or { left, right } => {
18133            if eval_trigger_condition(left, event, selected, schema)? {
18134                Ok(true)
18135            } else {
18136                Ok(eval_trigger_condition(right, event, selected, schema)?)
18137            }
18138        }
18139        TriggerCondition::Not(condition) => {
18140            Ok(!eval_trigger_condition(condition, event, selected, schema)?)
18141        }
18142    }
18143}
18144
18145fn eval_trigger_value(
18146    value: &TriggerValue,
18147    event: &WriteEvent,
18148    selected: Option<&TriggerRowImage>,
18149) -> Result<Value> {
18150    match value {
18151        TriggerValue::Literal(value) => Ok(value.clone()),
18152        TriggerValue::NewColumn(column_id) => event
18153            .new
18154            .as_ref()
18155            .and_then(|row| row.columns.get(column_id))
18156            .cloned()
18157            .ok_or_else(|| MongrelError::InvalidArgument("NEW column is not available".into())),
18158        TriggerValue::OldColumn(column_id) => event
18159            .old
18160            .as_ref()
18161            .and_then(|row| row.columns.get(column_id))
18162            .cloned()
18163            .ok_or_else(|| MongrelError::InvalidArgument("OLD column is not available".into())),
18164        TriggerValue::SelectedColumn(column_id) => selected
18165            .and_then(|row| row.columns.get(column_id))
18166            .cloned()
18167            .ok_or_else(|| {
18168                MongrelError::InvalidArgument("SELECTED column is not available".into())
18169            }),
18170    }
18171}
18172
18173fn values_equal(left: &Value, right: &Value) -> bool {
18174    match (left, right) {
18175        (Value::Null, Value::Null) => true,
18176        (Value::Bool(a), Value::Bool(b)) => a == b,
18177        (Value::Int64(a), Value::Int64(b)) => a == b,
18178        (Value::Float64(a), Value::Float64(b)) => a.to_bits() == b.to_bits(),
18179        (Value::Bytes(a), Value::Bytes(b)) => a == b,
18180        (a, b) if a.as_embedding().is_some() && b.as_embedding().is_some() => {
18181            let a = a.as_embedding().unwrap();
18182            let b = b.as_embedding().unwrap();
18183            a.len() == b.len()
18184                && a.iter()
18185                    .zip(b.iter())
18186                    .all(|(a, b)| a.to_bits() == b.to_bits())
18187        }
18188        _ => false,
18189    }
18190}
18191
18192fn value_order(left: &Value, right: &Value) -> Option<std::cmp::Ordering> {
18193    match (left, right) {
18194        (Value::Null, _) | (_, Value::Null) => None,
18195        (Value::Bool(a), Value::Bool(b)) => Some(a.cmp(b)),
18196        (Value::Int64(a), Value::Int64(b)) => Some(a.cmp(b)),
18197        // Cross-type Int64/Float64 comparison coerces the integer to f64.
18198        // This matches the spec but can lose precision for i64 values above 2^53.
18199        (Value::Int64(a), Value::Float64(b)) => {
18200            let af = *a as f64;
18201            Some(af.total_cmp(b))
18202        }
18203        // Cross-type Int64/Float64 comparison coerces the integer to f64.
18204        // This matches the spec but can lose precision for i64 values above 2^53.
18205        (Value::Float64(a), Value::Int64(b)) => {
18206            let bf = *b as f64;
18207            Some(a.total_cmp(&bf))
18208        }
18209        (Value::Float64(a), Value::Float64(b)) => Some(a.total_cmp(b)),
18210        (Value::Bytes(a), Value::Bytes(b)) => Some(a.cmp(b)),
18211        (a, b) if a.as_embedding().is_some() && b.as_embedding().is_some() => None,
18212        _ => None,
18213    }
18214}
18215
18216fn trigger_message(value: Value) -> String {
18217    match value {
18218        Value::Null => "NULL".into(),
18219        Value::Bool(value) => value.to_string(),
18220        Value::Int64(value) => value.to_string(),
18221        Value::Float64(value) => value.to_string(),
18222        Value::Bytes(value) => String::from_utf8_lossy(&value).into_owned(),
18223        Value::Embedding(value) => format!("{value:?}"),
18224        Value::GeneratedEmbedding(value) => format!("{:?}", value.vector),
18225        Value::Decimal(value) => value.to_string(),
18226        Value::Interval {
18227            months,
18228            days,
18229            nanos,
18230        } => format!("{months}m {days}d {nanos}ns"),
18231        Value::Uuid(b) => b.iter().map(|x| format!("{x:02x}")).collect(),
18232        Value::Json(b) => String::from_utf8_lossy(&b).into_owned(),
18233    }
18234}
18235
18236fn validate_trigger_step<'a>(
18237    step: &TriggerStep,
18238    cat: &'a Catalog,
18239    target_schema: &Schema,
18240    event: TriggerEvent,
18241    select_schemas: &mut HashMap<String, &'a Schema>,
18242) -> Result<()> {
18243    match step {
18244        TriggerStep::SetNew { cells } => {
18245            if event == TriggerEvent::Delete {
18246                return Err(MongrelError::InvalidArgument(
18247                    "SetNew trigger step is not valid for DELETE triggers".into(),
18248                ));
18249            }
18250            for cell in cells {
18251                validate_column_id(cell.column_id, target_schema)?;
18252                validate_trigger_value(&cell.value, target_schema, event)?;
18253            }
18254        }
18255        TriggerStep::Insert { table, cells } => {
18256            let schema = trigger_write_schema(cat, table, "insert")?;
18257            for cell in cells {
18258                validate_column_id(cell.column_id, schema)?;
18259                validate_trigger_value(&cell.value, target_schema, event)?;
18260            }
18261        }
18262        TriggerStep::UpdateByPk { table, pk, cells } => {
18263            let schema = trigger_write_schema(cat, table, "update")?;
18264            if schema.primary_key().is_none() {
18265                return Err(MongrelError::InvalidArgument(format!(
18266                    "trigger update_by_pk references table {table:?} without a primary key"
18267                )));
18268            }
18269            validate_trigger_value(pk, target_schema, event)?;
18270            for cell in cells {
18271                validate_column_id(cell.column_id, schema)?;
18272                validate_trigger_value(&cell.value, target_schema, event)?;
18273            }
18274        }
18275        TriggerStep::DeleteByPk { table, pk } => {
18276            let schema = trigger_write_schema(cat, table, "delete")?;
18277            if schema.primary_key().is_none() {
18278                return Err(MongrelError::InvalidArgument(format!(
18279                    "trigger delete_by_pk references table {table:?} without a primary key"
18280                )));
18281            }
18282            validate_trigger_value(pk, target_schema, event)?;
18283        }
18284        TriggerStep::Select {
18285            id,
18286            table,
18287            conditions,
18288        } => {
18289            let schema = trigger_read_schema(cat, table)?;
18290            for condition in conditions {
18291                validate_trigger_condition(condition, schema, target_schema, event)?;
18292            }
18293            if select_schemas.contains_key(id) {
18294                return Err(MongrelError::InvalidArgument(format!(
18295                    "duplicate select id {id:?} in trigger program"
18296                )));
18297            }
18298            select_schemas.insert(id.clone(), schema);
18299        }
18300        TriggerStep::Foreach { id, steps } => {
18301            if !select_schemas.contains_key(id) {
18302                return Err(MongrelError::InvalidArgument(format!(
18303                    "foreach references unknown select id {id:?}"
18304                )));
18305            }
18306            let mut inner_select_schemas = select_schemas.clone();
18307            for step in steps {
18308                validate_trigger_step(step, cat, target_schema, event, &mut inner_select_schemas)?;
18309            }
18310        }
18311        TriggerStep::DeleteWhere { table, conditions } => {
18312            let schema = trigger_write_schema(cat, table, "delete")?;
18313            for condition in conditions {
18314                validate_trigger_condition(condition, schema, target_schema, event)?;
18315            }
18316        }
18317        TriggerStep::UpdateWhere {
18318            table,
18319            conditions,
18320            cells,
18321        } => {
18322            let schema = trigger_write_schema(cat, table, "update")?;
18323            for condition in conditions {
18324                validate_trigger_condition(condition, schema, target_schema, event)?;
18325            }
18326            for cell in cells {
18327                validate_column_id(cell.column_id, schema)?;
18328                validate_trigger_value(&cell.value, target_schema, event)?;
18329            }
18330        }
18331        TriggerStep::Raise { message, .. } => {
18332            validate_trigger_value(message, target_schema, event)?
18333        }
18334    }
18335    Ok(())
18336}
18337
18338fn trigger_validation_error(error: MongrelError) -> MongrelError {
18339    match error {
18340        MongrelError::TriggerValidation(_) => error,
18341        MongrelError::InvalidArgument(message)
18342        | MongrelError::Conflict(message)
18343        | MongrelError::NotFound(message) => MongrelError::TriggerValidation(message),
18344        error => error,
18345    }
18346}
18347
18348fn trigger_write_schema<'a>(cat: &'a Catalog, table: &str, op: &str) -> Result<&'a Schema> {
18349    if let Some(entry) = cat.live(table) {
18350        return Ok(&entry.schema);
18351    }
18352    if let Some(entry) = cat.external_tables.iter().find(|entry| entry.name == table) {
18353        let allowed = match op {
18354            "insert" => entry.capabilities.writable || entry.capabilities.insert_only,
18355            "update" | "delete" => entry.capabilities.writable,
18356            _ => false,
18357        };
18358        if !allowed {
18359            return Err(MongrelError::InvalidArgument(format!(
18360                "trigger {op} references external table {table:?}, but module {:?} is not writable for that operation",
18361                entry.module
18362            )));
18363        }
18364        if !entry.capabilities.transaction_safe {
18365            return Err(MongrelError::InvalidArgument(format!(
18366                "trigger {op} references external table {table:?}, but module {:?} is not transaction-safe",
18367                entry.module
18368            )));
18369        }
18370        return Ok(&entry.declared_schema);
18371    }
18372    Err(MongrelError::InvalidArgument(format!(
18373        "trigger references unknown table {table:?}"
18374    )))
18375}
18376
18377fn trigger_read_schema<'a>(cat: &'a Catalog, table: &str) -> Result<&'a Schema> {
18378    if let Some(entry) = cat.live(table) {
18379        return Ok(&entry.schema);
18380    }
18381    if let Some(entry) = cat.external_tables.iter().find(|entry| entry.name == table) {
18382        if entry.capabilities.trigger_safe {
18383            return Ok(&entry.declared_schema);
18384        }
18385        return Err(MongrelError::InvalidArgument(format!(
18386            "trigger reads external table {table:?}, but module {:?} is not trigger-safe",
18387            entry.module
18388        )));
18389    }
18390    Err(MongrelError::InvalidArgument(format!(
18391        "trigger references unknown table {table:?}"
18392    )))
18393}
18394
18395fn validate_trigger_condition(
18396    condition: &TriggerCondition,
18397    schema: &Schema,
18398    target_schema: &Schema,
18399    event: TriggerEvent,
18400) -> Result<()> {
18401    match condition {
18402        TriggerCondition::Pk { value } => {
18403            if schema.primary_key().is_none() {
18404                return Err(MongrelError::InvalidArgument(
18405                    "trigger condition Pk references a table without a primary key".into(),
18406                ));
18407            }
18408            validate_trigger_value(value, target_schema, event)
18409        }
18410        TriggerCondition::Eq { column_id, value }
18411        | TriggerCondition::NotEq { column_id, value }
18412        | TriggerCondition::Lt { column_id, value }
18413        | TriggerCondition::Lte { column_id, value }
18414        | TriggerCondition::Gt { column_id, value }
18415        | TriggerCondition::Gte { column_id, value } => {
18416            validate_column_id(*column_id, schema)?;
18417            validate_trigger_value(value, target_schema, event)
18418        }
18419        TriggerCondition::IsNull { column_id } | TriggerCondition::IsNotNull { column_id } => {
18420            validate_column_id(*column_id, schema)
18421        }
18422        TriggerCondition::And { left, right } | TriggerCondition::Or { left, right } => {
18423            validate_trigger_condition(left, schema, target_schema, event)?;
18424            validate_trigger_condition(right, schema, target_schema, event)
18425        }
18426        TriggerCondition::Not(condition) => {
18427            validate_trigger_condition(condition, schema, target_schema, event)
18428        }
18429    }
18430}
18431
18432fn validate_trigger_expr(expr: &TriggerExpr, schema: &Schema, event: TriggerEvent) -> Result<()> {
18433    match expr {
18434        TriggerExpr::Value(value) | TriggerExpr::IsNull(value) | TriggerExpr::IsNotNull(value) => {
18435            validate_trigger_value(value, schema, event)
18436        }
18437        TriggerExpr::Eq { left, right }
18438        | TriggerExpr::NotEq { left, right }
18439        | TriggerExpr::Lt { left, right }
18440        | TriggerExpr::Lte { left, right }
18441        | TriggerExpr::Gt { left, right }
18442        | TriggerExpr::Gte { left, right } => {
18443            validate_trigger_value(left, schema, event)?;
18444            validate_trigger_value(right, schema, event)
18445        }
18446        TriggerExpr::And { left, right } | TriggerExpr::Or { left, right } => {
18447            validate_trigger_expr(left, schema, event)?;
18448            validate_trigger_expr(right, schema, event)
18449        }
18450        TriggerExpr::Not(expr) => validate_trigger_expr(expr, schema, event),
18451    }
18452}
18453
18454fn validate_trigger_value(
18455    value: &TriggerValue,
18456    schema: &Schema,
18457    event: TriggerEvent,
18458) -> Result<()> {
18459    match value {
18460        TriggerValue::Literal(_) => Ok(()),
18461        TriggerValue::NewColumn(id) => {
18462            if event == TriggerEvent::Delete {
18463                return Err(MongrelError::InvalidArgument(
18464                    "DELETE triggers cannot reference NEW".into(),
18465                ));
18466            }
18467            validate_column_id(*id, schema)
18468        }
18469        TriggerValue::OldColumn(id) => {
18470            if event == TriggerEvent::Insert {
18471                return Err(MongrelError::InvalidArgument(
18472                    "INSERT triggers cannot reference OLD".into(),
18473                ));
18474            }
18475            validate_column_id(*id, schema)
18476        }
18477        // SELECTED column references are only meaningful inside a foreach loop.
18478        // Strict loop-scope validation is deferred to runtime; the executor raises
18479        // an error if a selected row is not available.
18480        TriggerValue::SelectedColumn(_) => Ok(()),
18481    }
18482}
18483
18484/// Bound on the retained tail of the per-open commit-timestamp ledger
18485/// ([`Database::commit_ts_for_epoch`]): the read-your-writes lookup only ever
18486/// needs recent epochs, so the map keeps the newest commits and drops the
18487/// rest (a miss falls back to a fresh-begin HLC at the caller).
18488const COMMIT_TS_LEDGER_CAP: usize = 10_000;
18489
18490/// Rebuild the per-open epoch → commit-timestamp ledger from the validated
18491/// WAL recovery plan: `Op::TxnCommit` maps a transaction to its commit epoch
18492/// and the `Op::CommitTimestamp` record written ahead of it carries the
18493/// physical HLC component as nanos (spec §8.1). Reconstructed timestamps
18494/// carry `logical`/`node_tiebreaker` as 0 — the ledger byte format stores
18495/// micros only (same constraint [`crate::commit_log`] documents for replayed
18496/// receipts). Only the newest [`COMMIT_TS_LEDGER_CAP`] epochs are retained.
18497fn commit_ts_ledger_from_recovery(
18498    records: &[crate::wal::Record],
18499) -> std::collections::BTreeMap<u64, mongreldb_types::hlc::HlcTimestamp> {
18500    use crate::wal::Op;
18501    let mut commits = HashMap::new();
18502    let mut timestamps = HashMap::new();
18503    for record in records {
18504        match &record.op {
18505            Op::TxnCommit { epoch, .. } => {
18506                commits.insert(record.txn_id, *epoch);
18507            }
18508            Op::CommitTimestamp { unix_nanos } => {
18509                timestamps.insert(record.txn_id, *unix_nanos);
18510            }
18511            _ => {}
18512        }
18513    }
18514    let mut ledger = std::collections::BTreeMap::new();
18515    for (txn_id, epoch) in commits {
18516        let Some(unix_nanos) = timestamps.get(&txn_id) else {
18517            continue;
18518        };
18519        ledger.insert(
18520            epoch,
18521            mongreldb_types::hlc::HlcTimestamp {
18522                physical_micros: unix_nanos / 1_000,
18523                logical: 0,
18524                node_tiebreaker: 0,
18525            },
18526        );
18527    }
18528    while ledger.len() > COMMIT_TS_LEDGER_CAP {
18529        ledger.pop_first();
18530    }
18531    ledger
18532}
18533
18534/// Replay committed `Op::Ddl` records from the shared WAL into the catalog
18535/// (spec §15, review fix #16). A crash between WAL group-sync and the catalog
18536/// checkpoint leaves DDL durable in the WAL but absent from the on-disk
18537/// catalog. This pass closes that window by reconstructing missing entries
18538/// (and marking committed drops) before tables are mounted.
18539fn recover_ddl_from_wal(
18540    root: &Path,
18541    durable_root: Option<&crate::durable_file::DurableRoot>,
18542    target_catalog: &mut Catalog,
18543    meta_dek: Option<&[u8; META_DEK_LEN]>,
18544    wal_dek: Option<&zeroize::Zeroizing<[u8; 32]>>,
18545    apply: bool,
18546    table_roots: Option<&HashMap<u64, Arc<crate::durable_file::DurableRoot>>>,
18547) -> Result<()> {
18548    use crate::wal::SharedWal;
18549    let records = match durable_root {
18550        Some(root) => SharedWal::replay_durable_with_dek_limited(
18551            root,
18552            wal_dek,
18553            crate::wal::WalReplayLimits::from_env()?,
18554        )?,
18555        None => SharedWal::replay_with_dek(root, wal_dek)?,
18556    };
18557    recover_ddl_from_records(
18558        root,
18559        durable_root,
18560        target_catalog,
18561        meta_dek,
18562        apply,
18563        table_roots,
18564        &records,
18565    )
18566}
18567
18568fn recover_ddl_from_records(
18569    root: &Path,
18570    durable_root: Option<&crate::durable_file::DurableRoot>,
18571    target_catalog: &mut Catalog,
18572    meta_dek: Option<&[u8; META_DEK_LEN]>,
18573    apply: bool,
18574    table_roots: Option<&HashMap<u64, Arc<crate::durable_file::DurableRoot>>>,
18575    records: &[crate::wal::Record],
18576) -> Result<()> {
18577    use crate::wal::{DdlOp, Op};
18578
18579    let original_catalog = target_catalog.clone();
18580    let mut recovered_catalog = original_catalog.clone();
18581    let cat = &mut recovered_catalog;
18582    let mut created_table_ids = HashSet::<u64>::new();
18583    let mut ttl_updates = HashMap::<u64, (Option<crate::manifest::TtlPolicy>, u64)>::new();
18584
18585    let mut committed: HashMap<u64, u64> = HashMap::new();
18586    for r in records {
18587        if let Op::TxnCommit { epoch: ce, .. } = r.op {
18588            committed.insert(r.txn_id, ce);
18589        }
18590    }
18591    let catalog_snapshot_txns = records
18592        .iter()
18593        .filter_map(|record| {
18594            (committed.contains_key(&record.txn_id)
18595                && matches!(&record.op, Op::Ddl(DdlOp::CatalogSnapshot { .. })))
18596            .then_some(record.txn_id)
18597        })
18598        .collect::<HashSet<_>>();
18599
18600    let mut changed = false;
18601    let mut applied_catalog_epoch = cat.db_epoch;
18602    let max_committed_epoch = committed.values().copied().max().unwrap_or(cat.db_epoch);
18603    for r in records.iter().cloned() {
18604        let Some(&ce) = committed.get(&r.txn_id) else {
18605            continue;
18606        };
18607        let txn_id = r.txn_id;
18608        match r.op {
18609            Op::Ddl(DdlOp::CreateTable {
18610                table_id,
18611                ref name,
18612                ref schema_json,
18613            }) => {
18614                if cat.tables.iter().any(|t| t.table_id == table_id) {
18615                    continue;
18616                }
18617                let schema = DdlOp::decode_schema(schema_json)?;
18618                validate_recovered_schema(&schema)?;
18619                created_table_ids.insert(table_id);
18620                cat.tables.push(CatalogEntry {
18621                    table_id,
18622                    name: name.clone(),
18623                    schema,
18624                    state: TableState::Live,
18625                    created_epoch: ce,
18626                });
18627                cat.next_table_id =
18628                    cat.next_table_id
18629                        .max(table_id.checked_add(1).ok_or_else(|| {
18630                            MongrelError::Full("table id namespace exhausted".into())
18631                        })?);
18632                changed = true;
18633            }
18634            Op::Ddl(DdlOp::CreateBuildingTable {
18635                table_id,
18636                ref build_name,
18637                ref intended_name,
18638                ref query_id,
18639                created_at_unix_nanos,
18640                ref schema_json,
18641            }) => {
18642                if cat.tables.iter().any(|table| table.table_id == table_id) {
18643                    continue;
18644                }
18645                let schema = DdlOp::decode_schema(schema_json)?;
18646                validate_recovered_schema(&schema)?;
18647                created_table_ids.insert(table_id);
18648                cat.tables.push(CatalogEntry {
18649                    table_id,
18650                    name: build_name.clone(),
18651                    schema,
18652                    state: TableState::Building {
18653                        intended_name: intended_name.clone(),
18654                        query_id: query_id.clone(),
18655                        created_at_unix_nanos,
18656                        replaces_table_id: None,
18657                    },
18658                    created_epoch: ce,
18659                });
18660                cat.next_table_id =
18661                    cat.next_table_id
18662                        .max(table_id.checked_add(1).ok_or_else(|| {
18663                            MongrelError::Full("table id namespace exhausted".into())
18664                        })?);
18665                changed = true;
18666            }
18667            Op::Ddl(DdlOp::CreateRebuildingTable {
18668                table_id,
18669                ref build_name,
18670                ref intended_name,
18671                ref query_id,
18672                created_at_unix_nanos,
18673                replaces_table_id,
18674                ref schema_json,
18675            }) => {
18676                if cat.tables.iter().any(|table| table.table_id == table_id) {
18677                    continue;
18678                }
18679                let schema = DdlOp::decode_schema(schema_json)?;
18680                validate_recovered_schema(&schema)?;
18681                created_table_ids.insert(table_id);
18682                cat.tables.push(CatalogEntry {
18683                    table_id,
18684                    name: build_name.clone(),
18685                    schema,
18686                    state: TableState::Building {
18687                        intended_name: intended_name.clone(),
18688                        query_id: query_id.clone(),
18689                        created_at_unix_nanos,
18690                        replaces_table_id: Some(replaces_table_id),
18691                    },
18692                    created_epoch: ce,
18693                });
18694                cat.next_table_id =
18695                    cat.next_table_id
18696                        .max(table_id.checked_add(1).ok_or_else(|| {
18697                            MongrelError::Full("table id namespace exhausted".into())
18698                        })?);
18699                changed = true;
18700            }
18701            Op::Ddl(DdlOp::DropTable { table_id }) => {
18702                let mut dropped_name = None;
18703                if let Some(entry) = cat.tables.iter_mut().find(|t| t.table_id == table_id) {
18704                    if matches!(entry.state, TableState::Live | TableState::Building { .. }) {
18705                        dropped_name = Some(entry.name.clone());
18706                        entry.state = TableState::Dropped { at_epoch: ce };
18707                        changed = true;
18708                    }
18709                }
18710                if let Some(name) = dropped_name {
18711                    let before = cat.materialized_views.len();
18712                    cat.materialized_views
18713                        .retain(|definition| definition.name != name);
18714                    changed |= before != cat.materialized_views.len();
18715                    cat.security.rls_tables.retain(|table| table != &name);
18716                    cat.security.policies.retain(|policy| policy.table != name);
18717                    cat.security.masks.retain(|mask| mask.table != name);
18718                    for role in &mut cat.roles {
18719                        role.permissions
18720                            .retain(|permission| permission_table(permission) != Some(&name));
18721                    }
18722                    if !catalog_snapshot_txns.contains(&txn_id) {
18723                        advance_security_version(cat)?;
18724                    }
18725                }
18726            }
18727            Op::Ddl(DdlOp::PublishBuildingTable {
18728                table_id,
18729                ref new_name,
18730            }) => {
18731                if let Some(entry) = cat
18732                    .tables
18733                    .iter_mut()
18734                    .find(|table| table.table_id == table_id)
18735                {
18736                    if entry.name != *new_name || !matches!(entry.state, TableState::Live) {
18737                        entry.name = new_name.clone();
18738                        entry.state = TableState::Live;
18739                        changed = true;
18740                    }
18741                }
18742            }
18743            Op::Ddl(DdlOp::ReplaceBuildingTable {
18744                table_id,
18745                replaced_table_id,
18746                ref new_name,
18747            }) => {
18748                changed |=
18749                    apply_rebuilding_publish(cat, table_id, replaced_table_id, new_name, ce)?;
18750            }
18751            Op::Ddl(DdlOp::RenameTable {
18752                table_id,
18753                ref new_name,
18754            }) => {
18755                let mut old_name = None;
18756                if let Some(entry) = cat.tables.iter_mut().find(|t| t.table_id == table_id) {
18757                    if entry.name != *new_name {
18758                        old_name = Some(entry.name.clone());
18759                        entry.name = new_name.clone();
18760                        changed = true;
18761                    }
18762                }
18763                if let Some(old_name) = old_name {
18764                    if let Some(definition) = cat
18765                        .materialized_views
18766                        .iter_mut()
18767                        .find(|definition| definition.name == old_name)
18768                    {
18769                        definition.name = new_name.clone();
18770                    }
18771                    for table in &mut cat.security.rls_tables {
18772                        if *table == old_name {
18773                            *table = new_name.clone();
18774                        }
18775                    }
18776                    for policy in &mut cat.security.policies {
18777                        if policy.table == old_name {
18778                            policy.table = new_name.clone();
18779                        }
18780                    }
18781                    for mask in &mut cat.security.masks {
18782                        if mask.table == old_name {
18783                            mask.table = new_name.clone();
18784                        }
18785                    }
18786                    for role in &mut cat.roles {
18787                        for permission in &mut role.permissions {
18788                            rename_permission_table(permission, &old_name, new_name);
18789                        }
18790                    }
18791                    if !catalog_snapshot_txns.contains(&txn_id) {
18792                        advance_security_version(cat)?;
18793                    }
18794                }
18795                // If the entry is absent, its CreateTable was already
18796                // checkpointed carrying the post-rename name, so there is
18797                // nothing to apply — a no-op, not an error.
18798            }
18799            Op::Ddl(DdlOp::AlterTable {
18800                table_id,
18801                ref column_json,
18802            }) => {
18803                let column = DdlOp::decode_column(column_json)?;
18804                let mut renamed = None;
18805                if let Some(entry) = cat.tables.iter_mut().find(|t| t.table_id == table_id) {
18806                    renamed = entry
18807                        .schema
18808                        .columns
18809                        .iter()
18810                        .find(|existing| existing.id == column.id && existing.name != column.name)
18811                        .map(|existing| {
18812                            (
18813                                entry.name.clone(),
18814                                existing.name.clone(),
18815                                column.name.clone(),
18816                            )
18817                        });
18818                    if apply_recovered_column_def(&mut entry.schema, column)? {
18819                        validate_recovered_schema(&entry.schema)?;
18820                        changed = true;
18821                    }
18822                }
18823                if let Some((table, old_name, new_name)) = renamed {
18824                    for role in &mut cat.roles {
18825                        for permission in &mut role.permissions {
18826                            rename_permission_column(permission, &table, &old_name, &new_name);
18827                        }
18828                    }
18829                    if !catalog_snapshot_txns.contains(&txn_id) {
18830                        advance_security_version(cat)?;
18831                    }
18832                }
18833            }
18834            Op::Ddl(DdlOp::SetTtl {
18835                table_id,
18836                ref policy_json,
18837            }) => {
18838                let policy = DdlOp::decode_ttl(policy_json)?;
18839                let entry = cat
18840                    .tables
18841                    .iter()
18842                    .find(|entry| entry.table_id == table_id)
18843                    .ok_or_else(|| {
18844                        MongrelError::Schema(format!(
18845                            "recovered TTL references unknown table id {table_id}"
18846                        ))
18847                    })?;
18848                if let Some(policy) = policy {
18849                    let valid = entry
18850                        .schema
18851                        .columns
18852                        .iter()
18853                        .find(|column| column.id == policy.column_id)
18854                        .is_some_and(|column| {
18855                            column.ty == TypeId::TimestampNanos
18856                                && policy.duration_nanos > 0
18857                                && policy.duration_nanos <= i64::MAX as u64
18858                        });
18859                    if !valid {
18860                        return Err(MongrelError::Schema(format!(
18861                            "invalid recovered TTL policy for table id {table_id}"
18862                        )));
18863                    }
18864                }
18865                ttl_updates.insert(table_id, (policy, ce));
18866            }
18867            Op::Ddl(DdlOp::SetMaterializedView {
18868                ref name,
18869                ref definition_json,
18870            }) => {
18871                let definition = DdlOp::decode_materialized_view(definition_json)?;
18872                if definition.name != *name {
18873                    return Err(MongrelError::Schema(format!(
18874                        "materialized view WAL name mismatch: {name:?}"
18875                    )));
18876                }
18877                if cat.live(name).is_some() {
18878                    if let Some(existing) = cat
18879                        .materialized_views
18880                        .iter_mut()
18881                        .find(|existing| existing.name == *name)
18882                    {
18883                        if *existing != definition {
18884                            *existing = definition;
18885                            changed = true;
18886                        }
18887                    } else {
18888                        cat.materialized_views.push(definition);
18889                        changed = true;
18890                    }
18891                }
18892            }
18893            Op::Ddl(DdlOp::SetSecurityCatalog { ref security_json }) => {
18894                let security = DdlOp::decode_security(security_json)?;
18895                validate_security_catalog(cat, &security)?;
18896                if cat.security != security {
18897                    cat.security = security;
18898                    if !catalog_snapshot_txns.contains(&txn_id) {
18899                        advance_security_version(cat)?;
18900                    }
18901                    changed = true;
18902                }
18903            }
18904            Op::Ddl(DdlOp::SetSqlPragma { ref key, value }) => {
18905                let target = match key.as_str() {
18906                    "user_version" => &mut cat.user_version,
18907                    "application_id" => &mut cat.application_id,
18908                    _ => {
18909                        return Err(MongrelError::InvalidArgument(format!(
18910                            "unsupported recovered SQL pragma {key:?}"
18911                        )))
18912                    }
18913                };
18914                if *target != Some(value) {
18915                    *target = Some(value);
18916                    cat.db_epoch = cat.db_epoch.max(ce);
18917                    changed = true;
18918                }
18919            }
18920            Op::Ddl(DdlOp::CatalogSnapshot { ref catalog_json }) => {
18921                if ce <= applied_catalog_epoch {
18922                    continue;
18923                }
18924                let snapshot = DdlOp::decode_catalog(catalog_json)?;
18925                if snapshot.db_epoch != ce {
18926                    return Err(MongrelError::Schema(format!(
18927                        "catalog snapshot epoch {} does not match WAL commit epoch {ce}",
18928                        snapshot.db_epoch
18929                    )));
18930                }
18931                validate_recovered_catalog(&snapshot)?;
18932                validate_catalog_transition(cat, &snapshot)?;
18933                *cat = snapshot;
18934                applied_catalog_epoch = ce;
18935                changed = true;
18936            }
18937            _ => {}
18938        }
18939    }
18940
18941    if cat.db_epoch < max_committed_epoch {
18942        cat.db_epoch = max_committed_epoch;
18943        changed = true;
18944    }
18945    changed |= repair_catalog_allocator_counters(cat)?;
18946
18947    validate_recovered_catalog(cat)?;
18948    let storage_reconciliation = validate_recovered_storage_plan(
18949        root,
18950        durable_root,
18951        cat,
18952        &created_table_ids,
18953        &ttl_updates,
18954        meta_dek,
18955    )?;
18956
18957    let needs_storage_apply = !storage_reconciliation.is_empty() || !ttl_updates.is_empty();
18958    if apply && (changed || needs_storage_apply) {
18959        for table_id in storage_reconciliation {
18960            let entry = cat
18961                .tables
18962                .iter()
18963                .find(|entry| entry.table_id == table_id)
18964                .ok_or_else(|| MongrelError::CorruptWal {
18965                    offset: table_id,
18966                    reason: "recovery storage plan lost its catalog table".into(),
18967                })?;
18968            ensure_recovered_table_storage(
18969                table_roots
18970                    .and_then(|roots| roots.get(&table_id))
18971                    .map(Arc::as_ref),
18972                durable_root,
18973                &root.join(TABLES_DIR).join(table_id.to_string()),
18974                table_id,
18975                &entry.schema,
18976                meta_dek,
18977            )?;
18978        }
18979        for (table_id, (policy, ttl_epoch)) in ttl_updates {
18980            let Some(entry) = cat.tables.iter().find(|entry| {
18981                entry.table_id == table_id
18982                    && matches!(entry.state, TableState::Live | TableState::Building { .. })
18983            }) else {
18984                continue;
18985            };
18986            let table_root = if let Some(root) = table_roots.and_then(|roots| roots.get(&table_id))
18987            {
18988                root.try_clone()?
18989            } else if let Some(root) = durable_root {
18990                root.open_directory(Path::new(TABLES_DIR).join(table_id.to_string()))?
18991            } else {
18992                crate::durable_file::DurableRoot::open(
18993                    root.join(TABLES_DIR).join(table_id.to_string()),
18994                )?
18995            };
18996            let table_dir = table_root.io_path()?;
18997            let mut manifest = crate::manifest::read_durable(&table_root, "", meta_dek)?;
18998            if manifest.ttl != policy || manifest.current_epoch < ttl_epoch {
18999                manifest.ttl = policy;
19000                manifest.current_epoch = manifest.current_epoch.max(ttl_epoch);
19001                manifest.schema_id = entry.schema.schema_id;
19002                crate::manifest::write_atomic(&table_dir, &mut manifest, meta_dek)?;
19003            }
19004        }
19005        if changed {
19006            match durable_root {
19007                Some(root) => catalog::write_durable(root, cat, meta_dek)?,
19008                None => catalog::write_atomic(root, cat, meta_dek)?,
19009            }
19010        }
19011    }
19012    *target_catalog = recovered_catalog;
19013    Ok(())
19014}
19015
19016fn ensure_recovered_table_storage(
19017    pinned_table: Option<&crate::durable_file::DurableRoot>,
19018    durable_root: Option<&crate::durable_file::DurableRoot>,
19019    fallback_table_dir: &Path,
19020    table_id: u64,
19021    schema: &Schema,
19022    meta_dek: Option<&[u8; META_DEK_LEN]>,
19023) -> Result<()> {
19024    let table_root = if let Some(root) = pinned_table {
19025        root.try_clone()?
19026    } else if let Some(root) = durable_root {
19027        let relative = Path::new(TABLES_DIR).join(table_id.to_string());
19028        match root.open_directory(&relative) {
19029            Ok(table) => table,
19030            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
19031                root.create_directory_all_pinned(relative)?
19032            }
19033            Err(error) => return Err(error.into()),
19034        }
19035    } else {
19036        crate::durable_file::create_directory_all(fallback_table_dir)?;
19037        crate::durable_file::DurableRoot::open(fallback_table_dir)?
19038    };
19039    let table_dir = table_root.io_path()?;
19040    let mut existing_manifest = match crate::manifest::read_durable(&table_root, "", meta_dek) {
19041        Ok(manifest) => {
19042            if manifest.table_id != table_id {
19043                return Err(MongrelError::Conflict(format!(
19044                    "recovered table directory id mismatch: expected {table_id}, found {}",
19045                    manifest.table_id
19046                )));
19047            }
19048            Some(manifest)
19049        }
19050        Err(MongrelError::Io(error)) if error.kind() == std::io::ErrorKind::NotFound => None,
19051        Err(error) => return Err(error),
19052    };
19053
19054    table_root.create_directory_all(crate::engine::WAL_DIR)?;
19055    table_root.create_directory_all(crate::engine::RUNS_DIR)?;
19056    crate::engine::write_schema(&table_dir, schema)?;
19057
19058    if let Some(mut manifest) = existing_manifest.take() {
19059        if manifest.schema_id != schema.schema_id {
19060            manifest.schema_id = schema.schema_id;
19061            crate::manifest::write_atomic(&table_dir, &mut manifest, meta_dek)?;
19062        }
19063    } else {
19064        // The DB-wide meta DEK is also the per-table manifest meta DEK.
19065        let mut manifest = crate::manifest::Manifest::new(table_id, schema.schema_id);
19066        crate::manifest::write_atomic(&table_dir, &mut manifest, meta_dek)?;
19067    }
19068    Ok(())
19069}
19070
19071fn validate_recovered_schema(schema: &Schema) -> Result<()> {
19072    schema.validate_auto_increment()?;
19073    schema.validate_defaults()?;
19074    schema.validate_ai()?;
19075    let mut column_ids = HashSet::new();
19076    let mut column_names = HashSet::new();
19077    for column in &schema.columns {
19078        if !column_ids.insert(column.id) || !column_names.insert(column.name.as_str()) {
19079            return Err(MongrelError::Schema(
19080                "recovered schema contains duplicate columns".into(),
19081            ));
19082        }
19083        match &column.ty {
19084            TypeId::Decimal128 { precision, scale }
19085                if *precision == 0 || *precision > 38 || scale.unsigned_abs() > *precision =>
19086            {
19087                return Err(MongrelError::Schema(format!(
19088                    "column {:?} has invalid decimal precision or scale",
19089                    column.name
19090                )));
19091            }
19092            TypeId::Enum { variants }
19093                if variants.is_empty()
19094                    || variants.iter().any(String::is_empty)
19095                    || variants.iter().collect::<HashSet<_>>().len() != variants.len() =>
19096            {
19097                return Err(MongrelError::Schema(format!(
19098                    "column {:?} has invalid enum variants",
19099                    column.name
19100                )));
19101            }
19102            _ => {}
19103        }
19104    }
19105    let mut index_names = HashSet::new();
19106    for index in &schema.indexes {
19107        index.validate_options()?;
19108        if index.name.is_empty()
19109            || !index_names.insert(index.name.as_str())
19110            || schema
19111                .columns
19112                .iter()
19113                .all(|column| column.id != index.column_id)
19114        {
19115            return Err(MongrelError::Schema(format!(
19116                "recovered index {:?} references missing column {}",
19117                index.name, index.column_id
19118            )));
19119        }
19120    }
19121    let mut colocated = HashSet::new();
19122    for group in &schema.colocation {
19123        if group.is_empty()
19124            || group.iter().any(|id| !column_ids.contains(id))
19125            || group.iter().any(|id| !colocated.insert(*id))
19126        {
19127            return Err(MongrelError::Schema(
19128                "recovered schema contains invalid column co-location groups".into(),
19129            ));
19130        }
19131    }
19132
19133    let mut constraint_ids = HashSet::new();
19134    let mut constraint_names = HashSet::<String>::new();
19135    let mut validate_constraint_identity = |id: u16, name: &str| -> Result<()> {
19136        if name.is_empty()
19137            || !constraint_ids.insert(id)
19138            || !constraint_names.insert(name.to_owned())
19139        {
19140            return Err(MongrelError::Schema(
19141                "recovered schema contains duplicate or empty constraint identities".into(),
19142            ));
19143        }
19144        Ok(())
19145    };
19146    for unique in &schema.constraints.uniques {
19147        validate_constraint_identity(unique.id, &unique.name)?;
19148        if unique.columns.is_empty()
19149            || unique.columns.iter().any(|id| !column_ids.contains(id))
19150            || unique.columns.iter().collect::<HashSet<_>>().len() != unique.columns.len()
19151        {
19152            return Err(MongrelError::Schema(format!(
19153                "unique constraint {:?} has invalid columns",
19154                unique.name
19155            )));
19156        }
19157    }
19158    for foreign_key in &schema.constraints.foreign_keys {
19159        validate_constraint_identity(foreign_key.id, &foreign_key.name)?;
19160        if foreign_key.ref_table.is_empty()
19161            || foreign_key.columns.is_empty()
19162            || foreign_key.columns.len() != foreign_key.ref_columns.len()
19163            || foreign_key
19164                .columns
19165                .iter()
19166                .any(|id| !column_ids.contains(id))
19167            || foreign_key.columns.iter().collect::<HashSet<_>>().len() != foreign_key.columns.len()
19168            || foreign_key.ref_columns.iter().collect::<HashSet<_>>().len()
19169                != foreign_key.ref_columns.len()
19170        {
19171            return Err(MongrelError::Schema(format!(
19172                "foreign key {:?} has invalid columns",
19173                foreign_key.name
19174            )));
19175        }
19176        if (matches!(foreign_key.on_delete, crate::constraint::FkAction::SetNull)
19177            || matches!(foreign_key.on_update, crate::constraint::FkAction::SetNull))
19178            && foreign_key.columns.iter().any(|id| {
19179                schema
19180                    .columns
19181                    .iter()
19182                    .find(|column| column.id == *id)
19183                    .is_none_or(|column| {
19184                        !column.flags.contains(crate::schema::ColumnFlags::NULLABLE)
19185                    })
19186            })
19187        {
19188            return Err(MongrelError::Schema(format!(
19189                "foreign key {:?} uses SET NULL on a non-nullable column",
19190                foreign_key.name
19191            )));
19192        }
19193    }
19194    for check in &schema.constraints.checks {
19195        validate_constraint_identity(check.id, &check.name)?;
19196        check.expr.validate()?;
19197        validate_check_columns(&check.expr, &column_ids)?;
19198    }
19199    Ok(())
19200}
19201
19202fn validate_check_columns(
19203    expression: &crate::constraint::CheckExpr,
19204    column_ids: &HashSet<u16>,
19205) -> Result<()> {
19206    use crate::constraint::CheckExpr;
19207    match expression {
19208        CheckExpr::Col(id) | CheckExpr::IsNull(id) | CheckExpr::IsNotNull(id) => {
19209            if column_ids.contains(id) {
19210                Ok(())
19211            } else {
19212                Err(MongrelError::Schema(format!(
19213                    "check constraint references unknown column {id}"
19214                )))
19215            }
19216        }
19217        CheckExpr::Regex { col, .. } => {
19218            if column_ids.contains(col) {
19219                Ok(())
19220            } else {
19221                Err(MongrelError::Schema(format!(
19222                    "check constraint references unknown column {col}"
19223                )))
19224            }
19225        }
19226        CheckExpr::Add(left, right)
19227        | CheckExpr::Sub(left, right)
19228        | CheckExpr::Mul(left, right)
19229        | CheckExpr::Div(left, right)
19230        | CheckExpr::Mod(left, right)
19231        | CheckExpr::Eq(left, right)
19232        | CheckExpr::Ne(left, right)
19233        | CheckExpr::Lt(left, right)
19234        | CheckExpr::Le(left, right)
19235        | CheckExpr::Gt(left, right)
19236        | CheckExpr::Ge(left, right)
19237        | CheckExpr::And(left, right)
19238        | CheckExpr::Or(left, right) => {
19239            validate_check_columns(left, column_ids)?;
19240            validate_check_columns(right, column_ids)
19241        }
19242        CheckExpr::Not(inner) => validate_check_columns(inner, column_ids),
19243        CheckExpr::True | CheckExpr::Lit(_) => Ok(()),
19244    }
19245}
19246
19247fn validate_catalog_transition(current: &Catalog, next: &Catalog) -> Result<()> {
19248    for (name, prior, candidate) in [
19249        ("db_epoch", current.db_epoch, next.db_epoch),
19250        ("next_table_id", current.next_table_id, next.next_table_id),
19251        (
19252            "next_segment_no",
19253            current.next_segment_no,
19254            next.next_segment_no,
19255        ),
19256        ("next_user_id", current.next_user_id, next.next_user_id),
19257        (
19258            "security_version",
19259            current.security_version,
19260            next.security_version,
19261        ),
19262    ] {
19263        if candidate < prior {
19264            return Err(MongrelError::Schema(format!(
19265                "catalog snapshot rolls back {name} from {prior} to {candidate}"
19266            )));
19267        }
19268    }
19269    for prior in &current.tables {
19270        let Some(candidate) = next
19271            .tables
19272            .iter()
19273            .find(|entry| entry.table_id == prior.table_id)
19274        else {
19275            return Err(MongrelError::Schema(format!(
19276                "catalog snapshot removes table identity {}",
19277                prior.table_id
19278            )));
19279        };
19280        if candidate.created_epoch != prior.created_epoch
19281            || candidate.schema.schema_id < prior.schema.schema_id
19282            || matches!(prior.state, TableState::Dropped { .. })
19283                && !matches!(candidate.state, TableState::Dropped { .. })
19284        {
19285            return Err(MongrelError::Schema(format!(
19286                "catalog snapshot rolls back table identity {}",
19287                prior.table_id
19288            )));
19289        }
19290    }
19291    for prior in &current.users {
19292        if let Some(candidate) = next.users.iter().find(|user| user.id == prior.id) {
19293            if candidate.username != prior.username
19294                || candidate.created_epoch != prior.created_epoch
19295            {
19296                return Err(MongrelError::Schema(format!(
19297                    "catalog snapshot reuses user identity {}",
19298                    prior.id
19299                )));
19300            }
19301        }
19302    }
19303    Ok(())
19304}
19305
19306fn validate_recovered_catalog(catalog: &Catalog) -> Result<()> {
19307    let mut table_ids = HashSet::new();
19308    let mut active_names = HashSet::new();
19309    let mut max_table_id = None::<u64>;
19310    for entry in &catalog.tables {
19311        if !table_ids.insert(entry.table_id) {
19312            return Err(MongrelError::Schema(format!(
19313                "catalog contains duplicate table id {}",
19314                entry.table_id
19315            )));
19316        }
19317        max_table_id = Some(max_table_id.map_or(entry.table_id, |value| value.max(entry.table_id)));
19318        if entry.name.is_empty() || entry.created_epoch > catalog.db_epoch {
19319            return Err(MongrelError::Schema(format!(
19320                "catalog table {} has invalid name or creation epoch",
19321                entry.table_id
19322            )));
19323        }
19324        validate_recovered_schema(&entry.schema)?;
19325        match &entry.state {
19326            TableState::Live => {
19327                if !active_names.insert(entry.name.as_str()) {
19328                    return Err(MongrelError::Schema(format!(
19329                        "catalog contains duplicate active table name {:?}",
19330                        entry.name
19331                    )));
19332                }
19333            }
19334            TableState::Dropped { at_epoch } => {
19335                if *at_epoch < entry.created_epoch || *at_epoch > catalog.db_epoch {
19336                    return Err(MongrelError::Schema(format!(
19337                        "catalog table {} has invalid drop epoch {at_epoch}",
19338                        entry.table_id
19339                    )));
19340                }
19341            }
19342            TableState::Building {
19343                intended_name,
19344                query_id,
19345                replaces_table_id,
19346                ..
19347            } => {
19348                if intended_name.is_empty() || query_id.is_empty() {
19349                    return Err(MongrelError::Schema(format!(
19350                        "building table {} has empty identity fields",
19351                        entry.table_id
19352                    )));
19353                }
19354                if !active_names.insert(entry.name.as_str()) {
19355                    return Err(MongrelError::Schema(format!(
19356                        "catalog contains duplicate active/building table name {:?}",
19357                        entry.name
19358                    )));
19359                }
19360                if replaces_table_id.is_some_and(|id| id == entry.table_id) {
19361                    return Err(MongrelError::Schema(
19362                        "building table cannot replace itself".into(),
19363                    ));
19364                }
19365            }
19366        }
19367    }
19368    if let Some(maximum) = max_table_id {
19369        let required = maximum
19370            .checked_add(1)
19371            .ok_or_else(|| MongrelError::Full("table id namespace exhausted".into()))?;
19372        if catalog.next_table_id < required {
19373            return Err(MongrelError::Schema(format!(
19374                "catalog next_table_id {} precedes required {required}",
19375                catalog.next_table_id
19376            )));
19377        }
19378    }
19379    for entry in &catalog.tables {
19380        if let TableState::Building {
19381            replaces_table_id: Some(replaced),
19382            ..
19383        } = entry.state
19384        {
19385            if !table_ids.contains(&replaced) {
19386                return Err(MongrelError::Schema(format!(
19387                    "building table {} replaces unknown table {replaced}",
19388                    entry.table_id
19389                )));
19390            }
19391        }
19392    }
19393    for entry in &catalog.tables {
19394        if matches!(entry.state, TableState::Live | TableState::Building { .. }) {
19395            validate_foreign_key_targets(catalog, &entry.schema)?;
19396        }
19397    }
19398
19399    let mut external_names = HashSet::new();
19400    for entry in &catalog.external_tables {
19401        entry.validate()?;
19402        validate_recovered_schema(&entry.declared_schema)?;
19403        if !entry.declared_schema.constraints.is_empty() {
19404            return Err(MongrelError::Schema(format!(
19405                "external table {:?} cannot carry engine-enforced constraints",
19406                entry.name
19407            )));
19408        }
19409        if entry.created_epoch > catalog.db_epoch
19410            || !external_names.insert(entry.name.as_str())
19411            || active_names.contains(entry.name.as_str())
19412        {
19413            return Err(MongrelError::Schema(format!(
19414                "invalid or duplicate external table {:?}",
19415                entry.name
19416            )));
19417        }
19418    }
19419
19420    let mut procedure_names = HashSet::new();
19421    for entry in &catalog.procedures {
19422        entry.procedure.validate()?;
19423        if entry.procedure.created_epoch > entry.procedure.updated_epoch
19424            || entry.procedure.updated_epoch > catalog.db_epoch
19425            || !procedure_names.insert(entry.procedure.name.as_str())
19426        {
19427            return Err(MongrelError::Schema(format!(
19428                "invalid or duplicate procedure {:?}",
19429                entry.procedure.name
19430            )));
19431        }
19432        validate_recovered_procedure_references(catalog, &entry.procedure)?;
19433    }
19434
19435    let mut trigger_names = HashSet::new();
19436    for entry in &catalog.triggers {
19437        entry.trigger.validate()?;
19438        if entry.trigger.created_epoch > entry.trigger.updated_epoch
19439            || entry.trigger.updated_epoch > catalog.db_epoch
19440            || !trigger_names.insert(entry.trigger.name.as_str())
19441        {
19442            return Err(MongrelError::Schema(format!(
19443                "invalid or duplicate trigger {:?}",
19444                entry.trigger.name
19445            )));
19446        }
19447        validate_recovered_trigger_references(catalog, &entry.trigger)?;
19448    }
19449
19450    let mut views = HashSet::new();
19451    for view in &catalog.materialized_views {
19452        let target = catalog.live(&view.name).ok_or_else(|| {
19453            MongrelError::Schema(format!(
19454                "materialized view {:?} has no live table",
19455                view.name
19456            ))
19457        })?;
19458        if view.name.is_empty()
19459            || view.query.trim().is_empty()
19460            || view.last_refresh_epoch > catalog.db_epoch
19461            || !views.insert(view.name.as_str())
19462        {
19463            return Err(MongrelError::Schema(format!(
19464                "materialized view {:?} has no unique live table",
19465                view.name
19466            )));
19467        }
19468        if let Some(incremental) = &view.incremental {
19469            let source = catalog.live(&incremental.source_table).ok_or_else(|| {
19470                MongrelError::Schema(format!(
19471                    "materialized view {:?} references missing source {:?}",
19472                    view.name, incremental.source_table
19473                ))
19474            })?;
19475            if source.table_id != incremental.source_table_id
19476                || source
19477                    .schema
19478                    .columns
19479                    .iter()
19480                    .all(|column| column.id != incremental.group_column)
19481            {
19482                return Err(MongrelError::Schema(format!(
19483                    "materialized view {:?} has invalid incremental source",
19484                    view.name
19485                )));
19486            }
19487            let target_ids = target
19488                .schema
19489                .columns
19490                .iter()
19491                .map(|column| column.id)
19492                .collect::<HashSet<_>>();
19493            let mut output_ids = HashSet::new();
19494            let count_outputs = incremental
19495                .outputs
19496                .iter()
19497                .filter(|output| {
19498                    matches!(output.kind, crate::catalog::IncrementalAggregateKind::Count)
19499                })
19500                .count();
19501            if incremental.checkpoint_event_id.is_empty()
19502                || !target_ids.contains(&incremental.group_output_column)
19503                || !target_ids.contains(&incremental.count_output_column)
19504                || incremental.outputs.is_empty()
19505                || count_outputs != 1
19506                || incremental.outputs.iter().any(|output| {
19507                    !target_ids.contains(&output.output_column)
19508                        || output.output_column == incremental.group_output_column
19509                        || !output_ids.insert(output.output_column)
19510                        || matches!(output.kind, crate::catalog::IncrementalAggregateKind::Count)
19511                            && output.output_column != incremental.count_output_column
19512                        || match output.kind {
19513                            crate::catalog::IncrementalAggregateKind::Sum { source_column } => {
19514                                source
19515                                    .schema
19516                                    .columns
19517                                    .iter()
19518                                    .all(|column| column.id != source_column)
19519                            }
19520                            crate::catalog::IncrementalAggregateKind::Count => false,
19521                        }
19522                })
19523            {
19524                return Err(MongrelError::Schema(format!(
19525                    "materialized view {:?} has invalid incremental outputs",
19526                    view.name
19527                )));
19528            }
19529        }
19530    }
19531
19532    validate_security_catalog(catalog, &catalog.security)?;
19533    validate_recovered_auth_catalog(catalog)?;
19534    Ok(())
19535}
19536
19537fn repair_catalog_allocator_counters(catalog: &mut Catalog) -> Result<bool> {
19538    let mut changed = false;
19539    if let Some(maximum) = catalog.tables.iter().map(|entry| entry.table_id).max() {
19540        let required = maximum
19541            .checked_add(1)
19542            .ok_or_else(|| MongrelError::Full("table id namespace exhausted".into()))?;
19543        if catalog.next_table_id < required {
19544            catalog.next_table_id = required;
19545            changed = true;
19546        }
19547    }
19548    if let Some(maximum) = catalog.users.iter().map(|user| user.id).max() {
19549        let required = maximum
19550            .checked_add(1)
19551            .ok_or_else(|| MongrelError::Full("user id namespace exhausted".into()))?;
19552        if catalog.next_user_id < required {
19553            catalog.next_user_id = required;
19554            changed = true;
19555        }
19556    }
19557    Ok(changed)
19558}
19559
19560fn validate_foreign_key_targets(catalog: &Catalog, schema: &Schema) -> Result<()> {
19561    for foreign_key in &schema.constraints.foreign_keys {
19562        let parent = catalog.live(&foreign_key.ref_table).ok_or_else(|| {
19563            MongrelError::Schema(format!(
19564                "foreign key {:?} references unknown live table {:?}",
19565                foreign_key.name, foreign_key.ref_table
19566            ))
19567        })?;
19568        let referenced_unique = parent
19569            .schema
19570            .constraints
19571            .uniques
19572            .iter()
19573            .any(|unique| unique.columns == foreign_key.ref_columns)
19574            || foreign_key.ref_columns.len() == 1
19575                && parent
19576                    .schema
19577                    .primary_key()
19578                    .is_some_and(|column| column.id == foreign_key.ref_columns[0]);
19579        if !referenced_unique {
19580            return Err(MongrelError::Schema(format!(
19581                "foreign key {:?} does not reference a unique key",
19582                foreign_key.name
19583            )));
19584        }
19585        for (local_id, parent_id) in foreign_key.columns.iter().zip(&foreign_key.ref_columns) {
19586            let local = schema.columns.iter().find(|column| column.id == *local_id);
19587            let referenced = parent
19588                .schema
19589                .columns
19590                .iter()
19591                .find(|column| column.id == *parent_id);
19592            if local
19593                .zip(referenced)
19594                .is_none_or(|(local, referenced)| local.ty != referenced.ty)
19595            {
19596                return Err(MongrelError::Schema(format!(
19597                    "foreign key {:?} has missing or incompatible columns",
19598                    foreign_key.name
19599                )));
19600            }
19601        }
19602    }
19603    Ok(())
19604}
19605
19606fn validate_recovered_procedure_references(
19607    catalog: &Catalog,
19608    procedure: &StoredProcedure,
19609) -> Result<()> {
19610    for step in &procedure.body.steps {
19611        let Some(table_name) = step.table() else {
19612            continue;
19613        };
19614        let schema = &catalog
19615            .live(table_name)
19616            .ok_or_else(|| {
19617                MongrelError::Schema(format!(
19618                    "procedure {:?} references unknown table {table_name:?}",
19619                    procedure.name
19620                ))
19621            })?
19622            .schema;
19623        match step {
19624            ProcedureStep::NativeQuery {
19625                conditions,
19626                projection,
19627                ..
19628            } => {
19629                for condition in conditions {
19630                    validate_condition_columns(condition, schema)?;
19631                }
19632                for id in projection.iter().flatten() {
19633                    validate_column_id(*id, schema)?;
19634                }
19635            }
19636            ProcedureStep::Put { cells, .. } => {
19637                for cell in cells {
19638                    validate_column_id(cell.column_id, schema)?;
19639                }
19640            }
19641            ProcedureStep::Upsert {
19642                cells,
19643                update_cells,
19644                ..
19645            } => {
19646                for cell in cells.iter().chain(update_cells.iter().flatten()) {
19647                    validate_column_id(cell.column_id, schema)?;
19648                }
19649            }
19650            ProcedureStep::DeleteByPk { .. } if schema.primary_key().is_none() => {
19651                return Err(MongrelError::Schema(format!(
19652                    "procedure {:?} deletes by primary key on table without one",
19653                    procedure.name
19654                )));
19655            }
19656            ProcedureStep::DeleteByPk { .. }
19657            | ProcedureStep::DeleteRows { .. }
19658            | ProcedureStep::SqlQuery { .. } => {}
19659        }
19660    }
19661    Ok(())
19662}
19663
19664fn validate_recovered_trigger_references(catalog: &Catalog, trigger: &StoredTrigger) -> Result<()> {
19665    let target_schema = match &trigger.target {
19666        TriggerTarget::Table(name) => catalog
19667            .live(name)
19668            .ok_or_else(|| {
19669                MongrelError::Schema(format!(
19670                    "trigger {:?} references unknown table {name:?}",
19671                    trigger.name
19672                ))
19673            })?
19674            .schema
19675            .clone(),
19676        TriggerTarget::View(_) => Schema {
19677            columns: trigger.target_columns.clone(),
19678            ..Schema::default()
19679        },
19680    };
19681    for column in &trigger.update_of {
19682        if target_schema.column(column).is_none() {
19683            return Err(MongrelError::Schema(format!(
19684                "trigger {:?} references unknown UPDATE OF column {column:?}",
19685                trigger.name
19686            )));
19687        }
19688    }
19689    if let Some(expr) = &trigger.when {
19690        validate_trigger_expr(expr, &target_schema, trigger.event)?;
19691    }
19692    let mut selects = HashMap::new();
19693    for step in &trigger.program.steps {
19694        if matches!(step, TriggerStep::SetNew { .. }) && trigger.timing != TriggerTiming::Before {
19695            return Err(MongrelError::Schema(
19696                "SetNew is only valid in BEFORE triggers".into(),
19697            ));
19698        }
19699        validate_trigger_step(step, catalog, &target_schema, trigger.event, &mut selects)?;
19700    }
19701    Ok(())
19702}
19703
19704fn validate_recovered_auth_catalog(catalog: &Catalog) -> Result<()> {
19705    let mut role_names = HashSet::new();
19706    for role in &catalog.roles {
19707        if role.name.is_empty()
19708            || role.created_epoch > catalog.db_epoch
19709            || !role_names.insert(role.name.as_str())
19710        {
19711            return Err(MongrelError::Schema(format!(
19712                "invalid or duplicate role {:?}",
19713                role.name
19714            )));
19715        }
19716        for permission in &role.permissions {
19717            if let Some(table) = permission_table(permission) {
19718                let schema = catalog
19719                    .live(table)
19720                    .map(|entry| &entry.schema)
19721                    .or_else(|| {
19722                        catalog
19723                            .external_tables
19724                            .iter()
19725                            .find(|entry| entry.name == table)
19726                            .map(|entry| &entry.declared_schema)
19727                    })
19728                    .ok_or_else(|| {
19729                        MongrelError::Schema(format!(
19730                            "role {:?} references unknown table {table:?}",
19731                            role.name
19732                        ))
19733                    })?;
19734                let columns = match permission {
19735                    crate::auth::Permission::SelectColumns { columns, .. }
19736                    | crate::auth::Permission::InsertColumns { columns, .. }
19737                    | crate::auth::Permission::UpdateColumns { columns, .. } => Some(columns),
19738                    _ => None,
19739                };
19740                if columns.is_some_and(|columns| {
19741                    columns.is_empty()
19742                        || columns.iter().any(|column| schema.column(column).is_none())
19743                }) {
19744                    return Err(MongrelError::Schema(format!(
19745                        "role {:?} contains invalid column permissions",
19746                        role.name
19747                    )));
19748                }
19749            }
19750        }
19751    }
19752    let mut user_ids = HashSet::new();
19753    let mut usernames = HashSet::new();
19754    let mut maximum_user_id = 0;
19755    for user in &catalog.users {
19756        maximum_user_id = maximum_user_id.max(user.id);
19757        if user.id == 0
19758            || user.username.is_empty()
19759            || user.password_hash.is_empty()
19760            || user.created_epoch > catalog.db_epoch
19761            || !user_ids.insert(user.id)
19762            || !usernames.insert(user.username.as_str())
19763            || user
19764                .roles
19765                .iter()
19766                .any(|role| !role_names.contains(role.as_str()))
19767        {
19768            return Err(MongrelError::Schema(format!(
19769                "invalid or duplicate user {:?}",
19770                user.username
19771            )));
19772        }
19773    }
19774    if !catalog.users.is_empty() && catalog.next_user_id <= maximum_user_id {
19775        return Err(MongrelError::Schema(
19776            "catalog next_user_id does not advance beyond existing user ids".into(),
19777        ));
19778    }
19779    if catalog.require_auth && !catalog.users.iter().any(|user| user.is_admin) {
19780        return Err(MongrelError::Schema(
19781            "authenticated catalog has no administrator".into(),
19782        ));
19783    }
19784    Ok(())
19785}
19786
19787fn validate_recovered_storage_plan(
19788    root: &Path,
19789    durable_root: Option<&crate::durable_file::DurableRoot>,
19790    catalog: &Catalog,
19791    created_table_ids: &HashSet<u64>,
19792    ttl_updates: &HashMap<u64, (Option<crate::manifest::TtlPolicy>, u64)>,
19793    meta_dek: Option<&[u8; META_DEK_LEN]>,
19794) -> Result<Vec<u64>> {
19795    const MAX_SCHEMA_BYTES: u64 = 16 * 1024 * 1024;
19796    let mut reconcile = Vec::new();
19797    for entry in &catalog.tables {
19798        if !matches!(entry.state, TableState::Live | TableState::Building { .. }) {
19799            continue;
19800        }
19801        let relative_dir = Path::new(TABLES_DIR).join(entry.table_id.to_string());
19802        let table_dir = root.join(TABLES_DIR).join(entry.table_id.to_string());
19803        let table_exists = match durable_root {
19804            Some(root) => match root.open_directory(&relative_dir) {
19805                Ok(_) => true,
19806                Err(error) if error.kind() == std::io::ErrorKind::NotFound => false,
19807                Err(error) => return Err(error.into()),
19808            },
19809            None => table_dir.is_dir(),
19810        };
19811        if !table_exists {
19812            if created_table_ids.contains(&entry.table_id) {
19813                reconcile.push(entry.table_id);
19814                continue;
19815            }
19816            return Err(MongrelError::NotFound(format!(
19817                "catalog table {} storage is missing",
19818                entry.table_id
19819            )));
19820        }
19821        let manifest_result = match durable_root {
19822            Some(root) => crate::manifest::read_durable(root, &relative_dir, meta_dek),
19823            None => crate::manifest::read(&table_dir, meta_dek),
19824        };
19825        let manifest = match manifest_result {
19826            Ok(manifest) => manifest,
19827            Err(MongrelError::Io(error))
19828                if created_table_ids.contains(&entry.table_id)
19829                    && error.kind() == std::io::ErrorKind::NotFound =>
19830            {
19831                reconcile.push(entry.table_id);
19832                continue;
19833            }
19834            Err(error) => return Err(error),
19835        };
19836        if manifest.table_id != entry.table_id {
19837            return Err(MongrelError::Conflict(format!(
19838                "catalog table {} storage identity mismatch",
19839                entry.table_id
19840            )));
19841        }
19842        let schema_result = match durable_root {
19843            Some(root) => root
19844                .open_regular(relative_dir.join(crate::engine::SCHEMA_FILENAME))
19845                .map_err(MongrelError::from),
19846            None => crate::durable_file::open_regular_nofollow(
19847                &table_dir.join(crate::engine::SCHEMA_FILENAME),
19848            ),
19849        };
19850        let file = match schema_result {
19851            Ok(file) => file,
19852            Err(MongrelError::Io(error))
19853                if created_table_ids.contains(&entry.table_id)
19854                    && error.kind() == std::io::ErrorKind::NotFound =>
19855            {
19856                reconcile.push(entry.table_id);
19857                continue;
19858            }
19859            Err(error) => return Err(error),
19860        };
19861        let length = file.metadata()?.len();
19862        if length > MAX_SCHEMA_BYTES {
19863            return Err(MongrelError::ResourceLimitExceeded {
19864                resource: "recovered schema bytes",
19865                requested: usize::try_from(length).unwrap_or(usize::MAX),
19866                limit: MAX_SCHEMA_BYTES as usize,
19867            });
19868        }
19869        let disk_schema: Schema = serde_json::from_reader(file.take(MAX_SCHEMA_BYTES + 1))
19870            .map_err(|error| MongrelError::Schema(format!("decode recovered schema: {error}")))?;
19871        if manifest.schema_id != entry.schema.schema_id
19872            || crate::wal::DdlOp::encode_schema(&disk_schema)?
19873                != crate::wal::DdlOp::encode_schema(&entry.schema)?
19874        {
19875            reconcile.push(entry.table_id);
19876        }
19877    }
19878    for table_id in ttl_updates.keys() {
19879        if !catalog.tables.iter().any(|entry| {
19880            entry.table_id == *table_id
19881                && matches!(entry.state, TableState::Live | TableState::Building { .. })
19882        }) {
19883            continue;
19884        }
19885        let relative_dir = Path::new(TABLES_DIR).join(table_id.to_string());
19886        let table_exists = match durable_root {
19887            Some(root) => match root.open_directory(&relative_dir) {
19888                Ok(_) => true,
19889                Err(error) if error.kind() == std::io::ErrorKind::NotFound => false,
19890                Err(error) => return Err(error.into()),
19891            },
19892            None => root.join(&relative_dir).is_dir(),
19893        };
19894        if !table_exists && !created_table_ids.contains(table_id) {
19895            return Err(MongrelError::NotFound(format!(
19896                "TTL recovery table {table_id} storage is missing"
19897            )));
19898        }
19899    }
19900    reconcile.sort_unstable();
19901    reconcile.dedup();
19902    Ok(reconcile)
19903}
19904
19905fn validate_catalog_table_storage(
19906    root: &crate::durable_file::DurableRoot,
19907    catalog: &Catalog,
19908    meta_dek: Option<&[u8; META_DEK_LEN]>,
19909) -> Result<()> {
19910    for entry in &catalog.tables {
19911        if !matches!(entry.state, TableState::Live | TableState::Building { .. }) {
19912            continue;
19913        }
19914        let table_dir = Path::new(TABLES_DIR).join(entry.table_id.to_string());
19915        let manifest = crate::manifest::read_durable(root, &table_dir, meta_dek)?;
19916        if manifest.table_id != entry.table_id || manifest.schema_id != entry.schema.schema_id {
19917            return Err(MongrelError::Conflict(format!(
19918                "catalog table {} storage identity mismatch",
19919                entry.table_id
19920            )));
19921        }
19922        root.open_regular(table_dir.join(crate::engine::SCHEMA_FILENAME))?;
19923    }
19924    Ok(())
19925}
19926
19927fn apply_recovered_column_def(schema: &mut Schema, column: ColumnDef) -> Result<bool> {
19928    match schema.columns.iter_mut().find(|c| c.id == column.id) {
19929        Some(existing) if *existing == column => Ok(false),
19930        Some(existing) => {
19931            *existing = column;
19932            schema.schema_id = schema
19933                .schema_id
19934                .checked_add(1)
19935                .ok_or_else(|| MongrelError::Schema("schema id space exhausted".into()))?;
19936            Ok(true)
19937        }
19938        None => {
19939            schema.columns.push(column);
19940            schema.schema_id = schema
19941                .schema_id
19942                .checked_add(1)
19943                .ok_or_else(|| MongrelError::Schema("schema id space exhausted".into()))?;
19944            Ok(true)
19945        }
19946    }
19947}
19948
19949fn permission_table(permission: &crate::auth::Permission) -> Option<&str> {
19950    use crate::auth::Permission;
19951    match permission {
19952        Permission::Select { table }
19953        | Permission::Insert { table }
19954        | Permission::Update { table }
19955        | Permission::Delete { table }
19956        | Permission::SelectColumns { table, .. }
19957        | Permission::InsertColumns { table, .. }
19958        | Permission::UpdateColumns { table, .. } => Some(table),
19959        Permission::All | Permission::Ddl | Permission::Admin => None,
19960    }
19961}
19962
19963fn apply_rebuilding_publish(
19964    catalog: &mut Catalog,
19965    table_id: u64,
19966    replaced_table_id: u64,
19967    new_name: &str,
19968    epoch: u64,
19969) -> Result<bool> {
19970    let already_published = catalog.tables.iter().any(|entry| {
19971        entry.table_id == table_id
19972            && entry.name == new_name
19973            && matches!(entry.state, TableState::Live)
19974    }) && catalog.tables.iter().any(|entry| {
19975        entry.table_id == replaced_table_id && matches!(entry.state, TableState::Dropped { .. })
19976    });
19977    if already_published {
19978        return Ok(false);
19979    }
19980    let schema = catalog
19981        .tables
19982        .iter()
19983        .find(|entry| entry.table_id == table_id)
19984        .ok_or_else(|| MongrelError::NotFound(format!("table id {table_id} not found")))?
19985        .schema
19986        .clone();
19987    let replaced = catalog
19988        .tables
19989        .iter_mut()
19990        .find(|entry| entry.table_id == replaced_table_id)
19991        .ok_or_else(|| MongrelError::NotFound(format!("table id {replaced_table_id} not found")))?;
19992    replaced.state = TableState::Dropped { at_epoch: epoch };
19993    let replacement = catalog
19994        .tables
19995        .iter_mut()
19996        .find(|entry| entry.table_id == table_id)
19997        .ok_or_else(|| MongrelError::NotFound(format!("table id {table_id} not found")))?;
19998    replacement.name = new_name.to_string();
19999    replacement.state = TableState::Live;
20000
20001    for role in &mut catalog.roles {
20002        role.permissions.retain_mut(|permission| {
20003            retain_rebuilt_permission_columns(permission, new_name, &schema)
20004        });
20005    }
20006    for definition in &mut catalog.materialized_views {
20007        if let Some(incremental) = definition.incremental.as_mut() {
20008            if incremental.source_table == new_name
20009                && incremental.source_table_id == replaced_table_id
20010            {
20011                incremental.source_table_id = table_id;
20012            }
20013        }
20014    }
20015    advance_security_version(catalog)?;
20016    Ok(true)
20017}
20018
20019fn retain_rebuilt_permission_columns(
20020    permission: &mut crate::auth::Permission,
20021    target_table: &str,
20022    schema: &Schema,
20023) -> bool {
20024    use crate::auth::Permission;
20025    let columns = match permission {
20026        Permission::SelectColumns { table, columns }
20027        | Permission::InsertColumns { table, columns }
20028        | Permission::UpdateColumns { table, columns }
20029            if table == target_table =>
20030        {
20031            Some(columns)
20032        }
20033        _ => None,
20034    };
20035    if let Some(columns) = columns {
20036        columns.retain(|column| schema.column(column).is_some());
20037        !columns.is_empty()
20038    } else {
20039        true
20040    }
20041}
20042
20043fn rename_permission_table(permission: &mut crate::auth::Permission, old: &str, new: &str) {
20044    use crate::auth::Permission;
20045    let table = match permission {
20046        Permission::Select { table }
20047        | Permission::Insert { table }
20048        | Permission::Update { table }
20049        | Permission::Delete { table }
20050        | Permission::SelectColumns { table, .. }
20051        | Permission::InsertColumns { table, .. }
20052        | Permission::UpdateColumns { table, .. } => Some(table),
20053        Permission::All | Permission::Ddl | Permission::Admin => None,
20054    };
20055    if let Some(table) = table.filter(|table| table.as_str() == old) {
20056        *table = new.to_string();
20057    }
20058}
20059
20060fn rename_permission_column(
20061    permission: &mut crate::auth::Permission,
20062    target_table: &str,
20063    old: &str,
20064    new: &str,
20065) {
20066    use crate::auth::Permission;
20067    let columns = match permission {
20068        Permission::SelectColumns { table, columns }
20069        | Permission::InsertColumns { table, columns }
20070        | Permission::UpdateColumns { table, columns }
20071            if table == target_table =>
20072        {
20073            Some(columns)
20074        }
20075        _ => None,
20076    };
20077    if let Some(column) = columns
20078        .into_iter()
20079        .flatten()
20080        .find(|column| column.as_str() == old)
20081    {
20082        *column = new.to_string();
20083    }
20084}
20085
20086pub(crate) fn validate_security_catalog(
20087    catalog: &Catalog,
20088    security: &crate::security::SecurityCatalog,
20089) -> Result<()> {
20090    let mut policy_names = HashSet::new();
20091    for table in &security.rls_tables {
20092        if catalog.live(table).is_none() {
20093            return Err(MongrelError::NotFound(format!(
20094                "RLS table {table:?} not found"
20095            )));
20096        }
20097    }
20098    for policy in &security.policies {
20099        if !policy_names.insert((policy.table.clone(), policy.name.clone())) {
20100            return Err(MongrelError::InvalidArgument(format!(
20101                "duplicate policy {:?} on {:?}",
20102                policy.name, policy.table
20103            )));
20104        }
20105        let schema = &catalog
20106            .live(&policy.table)
20107            .ok_or_else(|| {
20108                MongrelError::NotFound(format!("policy table {:?} not found", policy.table))
20109            })?
20110            .schema;
20111        if let Some(expression) = &policy.using {
20112            validate_security_expression(expression, schema)?;
20113        }
20114        if let Some(expression) = &policy.with_check {
20115            validate_security_expression(expression, schema)?;
20116        }
20117    }
20118    let mut mask_names = HashSet::new();
20119    for mask in &security.masks {
20120        if !mask_names.insert((mask.table.clone(), mask.name.clone())) {
20121            return Err(MongrelError::InvalidArgument(format!(
20122                "duplicate mask {:?} on {:?}",
20123                mask.name, mask.table
20124            )));
20125        }
20126        let column = catalog
20127            .live(&mask.table)
20128            .and_then(|entry| {
20129                entry
20130                    .schema
20131                    .columns
20132                    .iter()
20133                    .find(|column| column.id == mask.column)
20134            })
20135            .ok_or_else(|| {
20136                MongrelError::NotFound(format!(
20137                    "mask column {} on {:?} not found",
20138                    mask.column, mask.table
20139                ))
20140            })?;
20141        if matches!(
20142            mask.strategy,
20143            crate::security::MaskStrategy::Redact { .. } | crate::security::MaskStrategy::Sha256
20144        ) && !matches!(column.ty, TypeId::Bytes | TypeId::Enum { .. })
20145        {
20146            return Err(MongrelError::InvalidArgument(format!(
20147                "mask {:?} requires a string/bytes column",
20148                mask.name
20149            )));
20150        }
20151    }
20152    Ok(())
20153}
20154
20155fn validate_security_expression(
20156    expression: &crate::security::SecurityExpr,
20157    schema: &Schema,
20158) -> Result<()> {
20159    use crate::security::SecurityExpr;
20160    match expression {
20161        SecurityExpr::True => Ok(()),
20162        SecurityExpr::ColumnEqCurrentUser { column }
20163        | SecurityExpr::ColumnEqValue { column, .. } => {
20164            if schema
20165                .columns
20166                .iter()
20167                .any(|candidate| candidate.id == *column)
20168            {
20169                Ok(())
20170            } else {
20171                Err(MongrelError::InvalidArgument(format!(
20172                    "security expression references unknown column id {column}"
20173                )))
20174            }
20175        }
20176        SecurityExpr::And { left, right } | SecurityExpr::Or { left, right } => {
20177            validate_security_expression(left, schema)?;
20178            validate_security_expression(right, schema)
20179        }
20180        SecurityExpr::Not { expression } => validate_security_expression(expression, schema),
20181    }
20182}
20183
20184/// Remove canonical numeric table directories that no catalog generation owns.
20185fn sweep_unreferenced_table_dirs(root: &Path, cat: &Catalog) -> Result<()> {
20186    let referenced = cat
20187        .tables
20188        .iter()
20189        .filter(|entry| matches!(entry.state, TableState::Live | TableState::Building { .. }))
20190        .map(|entry| entry.table_id)
20191        .collect::<HashSet<_>>();
20192    let tables_dir = root.join(TABLES_DIR);
20193    let entries = match std::fs::read_dir(&tables_dir) {
20194        Ok(entries) => entries,
20195        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
20196        Err(error) => return Err(error.into()),
20197    };
20198    for entry in entries {
20199        let entry = entry?;
20200        if !entry.file_type()?.is_dir() {
20201            continue;
20202        }
20203        let file_name = entry.file_name();
20204        let Some(name) = file_name.to_str() else {
20205            continue;
20206        };
20207        let Ok(table_id) = name.parse::<u64>() else {
20208            continue;
20209        };
20210        if name != table_id.to_string() {
20211            continue;
20212        }
20213        if !referenced.contains(&table_id) {
20214            crate::durable_file::remove_directory_all(&entry.path())?;
20215        }
20216    }
20217    Ok(())
20218}
20219
20220/// Sweep stale `_txn/<txn_id>/` dirs from every table (spec §8.5, review fix
20221/// #14). These dirs hold pending uniform-epoch runs from large transactions
20222/// that were aborted or crashed before commit. On open, all such dirs are safe
20223/// to remove because committed txns moved their runs to `_runs/` at publish.
20224fn sweep_pending_txn_dirs(root: &Path, cat: &Catalog) {
20225    for entry in &cat.tables {
20226        let txn_dir = root
20227            .join(TABLES_DIR)
20228            .join(entry.table_id.to_string())
20229            .join("_txn");
20230        if txn_dir.exists() {
20231            let _ = std::fs::remove_dir_all(&txn_dir);
20232        }
20233    }
20234}
20235
20236#[cfg(test)]
20237mod write_permission_tests {
20238    use super::*;
20239    use crate::txn::Staged;
20240
20241    struct NoopExternalBridge;
20242
20243    impl ExternalTriggerBridge for NoopExternalBridge {
20244        fn apply_trigger_external_write(
20245            &self,
20246            _entry: &ExternalTableEntry,
20247            base_state: Vec<u8>,
20248            _op: ExternalTriggerWrite,
20249        ) -> Result<ExternalTriggerWriteResult> {
20250            Ok(ExternalTriggerWriteResult::new(base_state))
20251        }
20252    }
20253
20254    fn assert_txn_namespace_full<T>(result: Result<T>) {
20255        assert!(matches!(result, Err(MongrelError::Full(_))));
20256    }
20257
20258    #[test]
20259    fn every_begin_api_preserves_transaction_id_exhaustion_without_wal_mutation() {
20260        let directory = tempfile::tempdir().unwrap();
20261        let database = Database::create(directory.path()).unwrap();
20262        let generation = (*database.next_txn_id.lock() >> 32).saturating_add(1);
20263        *database.next_txn_id.lock() = generation << 32;
20264        let before = crate::wal::SharedWal::replay(directory.path())
20265            .unwrap()
20266            .len();
20267        let bridge = NoopExternalBridge;
20268
20269        assert_txn_namespace_full(database.begin().commit());
20270        assert_txn_namespace_full(database.begin_as(None).commit_with_row_ids());
20271        assert_txn_namespace_full(
20272            database
20273                .begin_with_isolation(crate::txn::IsolationLevel::Serializable)
20274                .commit(),
20275        );
20276        assert_txn_namespace_full(
20277            database
20278                .begin_with_external_trigger_bridge(&bridge)
20279                .commit(),
20280        );
20281        assert_txn_namespace_full(
20282            database
20283                .begin_with_external_trigger_bridge_as(&bridge, None)
20284                .commit_controlled(&crate::ExecutionControl::new(None), || Ok(())),
20285        );
20286
20287        assert_eq!(
20288            crate::wal::SharedWal::replay(directory.path())
20289                .unwrap()
20290                .len(),
20291            before
20292        );
20293        drop(database);
20294        Database::open(directory.path()).unwrap();
20295    }
20296
20297    #[test]
20298    fn recovered_storage_identity_mismatch_does_not_mutate_directory() {
20299        let directory = tempfile::tempdir().unwrap();
20300        let table_dir = directory.path().join("7");
20301        crate::durable_file::create_directory_all(&table_dir).unwrap();
20302        let original_schema = test_schema();
20303        crate::engine::write_schema(&table_dir, &original_schema).unwrap();
20304        let mut manifest = crate::manifest::Manifest::new(8, original_schema.schema_id);
20305        crate::manifest::write_atomic(&table_dir, &mut manifest, None).unwrap();
20306        let schema_path = table_dir.join(crate::engine::SCHEMA_FILENAME);
20307        let original_bytes = std::fs::read(&schema_path).unwrap();
20308
20309        let mut replacement_schema = original_schema;
20310        replacement_schema.schema_id += 1;
20311        assert!(matches!(
20312            ensure_recovered_table_storage(None, None, &table_dir, 7, &replacement_schema, None,),
20313            Err(MongrelError::Conflict(_))
20314        ));
20315
20316        assert_eq!(std::fs::read(schema_path).unwrap(), original_bytes);
20317        assert!(!table_dir.join(crate::engine::WAL_DIR).exists());
20318        assert!(!table_dir.join(crate::engine::RUNS_DIR).exists());
20319        assert_eq!(crate::manifest::read(&table_dir, None).unwrap().table_id, 8);
20320    }
20321
20322    #[test]
20323    fn catalog_table_missing_storage_fails_without_recreating_it() {
20324        let directory = tempfile::tempdir().unwrap();
20325        let table_dir = {
20326            let database = Database::create(directory.path()).unwrap();
20327            database.create_table("docs", test_schema()).unwrap();
20328            directory
20329                .path()
20330                .join(TABLES_DIR)
20331                .join(database.table_id("docs").unwrap().to_string())
20332        };
20333        std::fs::remove_dir_all(&table_dir).unwrap();
20334
20335        assert!(matches!(
20336            Database::open(directory.path()),
20337            Err(MongrelError::NotFound(_))
20338        ));
20339        assert!(!table_dir.exists());
20340    }
20341
20342    #[test]
20343    fn authentication_and_principal_resolution_share_one_catalog_snapshot() {
20344        let directory = tempfile::tempdir().unwrap();
20345        let database = std::sync::Arc::new(
20346            Database::create_with_credentials(directory.path(), "admin", "admin-password").unwrap(),
20347        );
20348        database.create_user("alice", "old-password").unwrap();
20349        let old_identity = database.user_identity("alice").unwrap();
20350        let (verified_tx, verified_rx) = std::sync::mpsc::channel();
20351        let (resume_tx, resume_rx) = std::sync::mpsc::channel();
20352        let (mutation_started_tx, mutation_started_rx) = std::sync::mpsc::channel();
20353        let (mutation_done_tx, mutation_done_rx) = std::sync::mpsc::channel();
20354
20355        std::thread::scope(|scope| {
20356            let authenticate = {
20357                let database = std::sync::Arc::clone(&database);
20358                scope.spawn(move || {
20359                    database.authenticate_principal_inner("alice", "old-password", || {
20360                        verified_tx.send(()).unwrap();
20361                        resume_rx.recv().unwrap();
20362                    })
20363                })
20364            };
20365            verified_rx.recv().unwrap();
20366            let mutate = {
20367                let database = std::sync::Arc::clone(&database);
20368                scope.spawn(move || {
20369                    mutation_started_tx.send(()).unwrap();
20370                    database.drop_user("alice").unwrap();
20371                    database.create_user("alice", "new-password").unwrap();
20372                    mutation_done_tx.send(()).unwrap();
20373                })
20374            };
20375            mutation_started_rx.recv().unwrap();
20376            assert!(mutation_done_rx
20377                .recv_timeout(std::time::Duration::from_millis(50))
20378                .is_err());
20379            resume_tx.send(()).unwrap();
20380            let principal = authenticate.join().unwrap().unwrap().unwrap();
20381            assert_eq!((principal.user_id, principal.created_epoch), old_identity);
20382            mutate.join().unwrap();
20383        });
20384
20385        assert_ne!(database.user_identity("alice").unwrap(), old_identity);
20386        assert!(database
20387            .authenticate_principal("alice", "old-password")
20388            .unwrap()
20389            .is_none());
20390        assert!(database
20391            .authenticate_principal("alice", "new-password")
20392            .unwrap()
20393            .is_some());
20394    }
20395
20396    #[test]
20397    fn homogeneous_batch_summarizes_to_one_permission_decision() {
20398        let staging = (0..10_050)
20399            .map(|_| {
20400                (
20401                    7,
20402                    Staged::Put(vec![(2, Value::Int64(2)), (1, Value::Int64(1))]),
20403                )
20404            })
20405            .collect::<Vec<_>>();
20406
20407        let needs = summarize_write_permissions(&staging);
20408        let table = needs.get(&7).unwrap();
20409        assert_eq!(needs.len(), 1);
20410        assert!(table.insert);
20411        assert_eq!(table.insert_columns, [1, 2]);
20412        assert!(!table.update);
20413        assert!(!table.delete);
20414        assert!(!table.truncate);
20415    }
20416
20417    #[test]
20418    fn mixed_writes_union_columns_and_preserve_empty_operations() {
20419        let staging = vec![
20420            (7, Staged::Put(vec![(2, Value::Int64(2))])),
20421            (7, Staged::Put(vec![(1, Value::Int64(1))])),
20422            (
20423                7,
20424                Staged::Update {
20425                    row_id: RowId(1),
20426                    new_row: vec![(1, Value::Int64(1)), (2, Value::Int64(2))],
20427                    changed_columns: vec![2],
20428                },
20429            ),
20430            (7, Staged::Delete(RowId(2))),
20431            (8, Staged::Truncate),
20432        ];
20433
20434        let needs = summarize_write_permissions(&staging);
20435        let table = needs.get(&7).unwrap();
20436        assert_eq!(table.insert_columns, [1, 2]);
20437        assert!(table.update);
20438        assert_eq!(table.update_columns, [2]);
20439        assert!(table.delete);
20440        assert!(needs.get(&8).unwrap().truncate);
20441    }
20442
20443    #[test]
20444    fn final_permission_decisions_do_not_scale_with_rows() {
20445        let credentialless_dir = tempfile::tempdir().unwrap();
20446        let credentialless = Database::create(credentialless_dir.path()).unwrap();
20447        credentialless.create_table("docs", test_schema()).unwrap();
20448        WRITE_PERMISSION_DECISIONS.with(|decisions| decisions.set(0));
20449        credentialless
20450            .validate_write_permissions(&puts(credentialless.table_id("docs").unwrap()), None, None)
20451            .unwrap();
20452        WRITE_PERMISSION_DECISIONS.with(|decisions| assert_eq!(decisions.get(), 0));
20453
20454        let authenticated_dir = tempfile::tempdir().unwrap();
20455        let authenticated =
20456            Database::create_with_credentials(authenticated_dir.path(), "admin", "admin-password")
20457                .unwrap();
20458        authenticated.create_table("docs", test_schema()).unwrap();
20459        let admin = authenticated.resolve_principal("admin").unwrap();
20460        WRITE_PERMISSION_DECISIONS.with(|decisions| decisions.set(0));
20461        authenticated
20462            .validate_write_permissions(
20463                &puts(authenticated.table_id("docs").unwrap()),
20464                Some(&admin),
20465                None,
20466            )
20467            .unwrap();
20468        WRITE_PERMISSION_DECISIONS.with(|decisions| assert_eq!(decisions.get(), 1));
20469    }
20470
20471    #[test]
20472    fn delete_batch_checks_permission_once_when_staged_and_once_when_committed() {
20473        let dir = tempfile::tempdir().unwrap();
20474        let db = Database::create_with_credentials(dir.path(), "admin", "admin-password").unwrap();
20475        db.create_table("docs", test_schema()).unwrap();
20476        let admin = db.resolve_principal("admin").unwrap();
20477        TABLE_PERMISSION_DECISIONS.with(|decisions| decisions.set(0));
20478
20479        let mut transaction = db.begin_as(Some(admin));
20480        transaction
20481            .delete_batch("docs", (0..100).map(RowId).collect())
20482            .unwrap();
20483        transaction.commit().unwrap();
20484
20485        TABLE_PERMISSION_DECISIONS.with(|decisions| assert_eq!(decisions.get(), 2));
20486    }
20487
20488    #[test]
20489    fn truncate_validation_checks_admin_once_for_all_tables() {
20490        let dir = tempfile::tempdir().unwrap();
20491        let db = Database::create_with_credentials(dir.path(), "admin", "admin-password").unwrap();
20492        db.create_table("first", test_schema()).unwrap();
20493        db.create_table("second", test_schema()).unwrap();
20494        let admin = db.resolve_principal("admin").unwrap();
20495        let staging = vec![
20496            (db.table_id("first").unwrap(), Staged::Truncate),
20497            (db.table_id("second").unwrap(), Staged::Truncate),
20498        ];
20499
20500        TABLE_PERMISSION_DECISIONS.with(|decisions| decisions.set(0));
20501        db.validate_write_permissions(&staging, Some(&admin), None)
20502            .unwrap();
20503        TABLE_PERMISSION_DECISIONS.with(|decisions| assert_eq!(decisions.get(), 1));
20504    }
20505
20506    #[test]
20507    fn one_table_commit_batches_structural_work() {
20508        let dir = tempfile::tempdir().unwrap();
20509        let db = Database::create(dir.path()).unwrap();
20510        db.create_table("docs", test_schema()).unwrap();
20511        let table_id = db.table_id("docs").unwrap();
20512
20513        AUTO_INCREMENT_TABLE_LOCKS.with(|count| count.set(0));
20514        PREBUILD_TABLE_LOCKS.with(|count| count.set(0));
20515        PUBLISH_TABLE_LOCKS.with(|count| count.set(0));
20516        COMMIT_MANIFEST_WRITES.with(|count| count.set(0));
20517        db.transaction(|transaction| {
20518            for id in 0..100 {
20519                transaction.put("docs", vec![(1, Value::Int64(id))])?;
20520            }
20521            Ok(())
20522        })
20523        .unwrap();
20524
20525        AUTO_INCREMENT_TABLE_LOCKS.with(|count| assert_eq!(count.get(), 2));
20526        PREBUILD_TABLE_LOCKS.with(|count| assert_eq!(count.get(), 1));
20527        PUBLISH_TABLE_LOCKS.with(|count| assert_eq!(count.get(), 1));
20528        COMMIT_MANIFEST_WRITES.with(|count| assert_eq!(count.get(), 1));
20529
20530        let puts = crate::wal::SharedWal::replay(dir.path())
20531            .unwrap()
20532            .into_iter()
20533            .filter_map(|record| match record.op {
20534                crate::wal::Op::Put { table_id: id, rows } if id == table_id => Some(
20535                    bincode::deserialize::<Vec<crate::memtable::Row>>(&rows)
20536                        .unwrap()
20537                        .len(),
20538                ),
20539                _ => None,
20540            })
20541            .collect::<Vec<_>>();
20542        assert_eq!(puts, [100]);
20543
20544        let row_ids = db
20545            .table("docs")
20546            .unwrap()
20547            .lock()
20548            .visible_rows(db.snapshot().0)
20549            .unwrap()
20550            .into_iter()
20551            .take(2)
20552            .map(|row| row.row_id)
20553            .collect::<Vec<_>>();
20554        PREBUILD_TABLE_LOCKS.with(|count| count.set(0));
20555        PUBLISH_TABLE_LOCKS.with(|count| count.set(0));
20556        COMMIT_MANIFEST_WRITES.with(|count| count.set(0));
20557        db.transaction(|transaction| {
20558            for row_id in row_ids {
20559                transaction.delete("docs", row_id)?;
20560            }
20561            Ok(())
20562        })
20563        .unwrap();
20564        PREBUILD_TABLE_LOCKS.with(|count| assert_eq!(count.get(), 1));
20565        PUBLISH_TABLE_LOCKS.with(|count| assert_eq!(count.get(), 1));
20566        COMMIT_MANIFEST_WRITES.with(|count| assert_eq!(count.get(), 1));
20567
20568        let deletes = crate::wal::SharedWal::replay(dir.path())
20569            .unwrap()
20570            .into_iter()
20571            .filter_map(|record| match record.op {
20572                crate::wal::Op::Delete {
20573                    table_id: id,
20574                    row_ids,
20575                } if id == table_id => Some(row_ids.len()),
20576                _ => None,
20577            })
20578            .collect::<Vec<_>>();
20579        assert_eq!(deletes, [2]);
20580    }
20581
20582    fn puts(table_id: u64) -> Vec<(u64, Staged)> {
20583        (0..10_050)
20584            .map(|id| (table_id, Staged::Put(vec![(1, Value::Int64(id))])))
20585            .collect()
20586    }
20587
20588    fn test_schema() -> Schema {
20589        Schema {
20590            columns: vec![ColumnDef {
20591                id: 1,
20592                name: "id".into(),
20593                ty: TypeId::Int64,
20594                flags: crate::schema::ColumnFlags::empty()
20595                    .with(crate::schema::ColumnFlags::PRIMARY_KEY),
20596                default_value: None,
20597                embedding_source: None,
20598            }],
20599            ..Schema::default()
20600        }
20601    }
20602}
20603
20604#[cfg(test)]
20605mod cdc_bounds_tests {
20606    use super::*;
20607
20608    #[test]
20609    fn retained_byte_limit_rejects_without_allocating_payload() {
20610        let mut retained = 0;
20611        let error = charge_cdc_bytes(
20612            &mut retained,
20613            CDC_MAX_RETAINED_BYTES.saturating_add(1),
20614            "CDC retained bytes",
20615        )
20616        .unwrap_err();
20617        assert!(matches!(
20618            error,
20619            MongrelError::ResourceLimitExceeded {
20620                resource: "CDC retained bytes",
20621                ..
20622            }
20623        ));
20624    }
20625
20626    #[test]
20627    fn row_json_estimate_accounts_for_byte_array_expansion() {
20628        let row = crate::memtable::Row::new(RowId(1), Epoch(1))
20629            .with_column(1, Value::Bytes(vec![0; 1024]));
20630        assert!(cdc_row_json_bytes(&row) >= 1024 * std::mem::size_of::<serde_json::Value>());
20631    }
20632}
20633
20634#[cfg(test)]
20635mod generation_metrics_tests {
20636    use super::*;
20637    use crate::schema::{ColumnDef, ColumnFlags, Schema, TypeId};
20638
20639    #[test]
20640    fn legacy_cow_fallback_is_measured() {
20641        let dir = tempfile::tempdir().unwrap();
20642        let table = Table::create(
20643            dir.path(),
20644            Schema {
20645                columns: vec![ColumnDef {
20646                    id: 1,
20647                    name: "id".into(),
20648                    ty: TypeId::Int64,
20649                    flags: ColumnFlags::empty().with(ColumnFlags::PRIMARY_KEY),
20650                    default_value: None,
20651                    embedding_source: None,
20652                }],
20653                ..Schema::default()
20654            },
20655            1,
20656        )
20657        .unwrap();
20658        let handle = TableHandle::from_table(table);
20659        let held = match &handle.inner {
20660            TableHandleInner::CopyOnWrite(slot) => Arc::clone(&slot.read()),
20661            TableHandleInner::Direct(_) => unreachable!(),
20662        };
20663
20664        handle.lock().set_sync_byte_threshold(1);
20665
20666        let stats = handle.generation_stats();
20667        assert_eq!(stats.cow_clone_count, 1);
20668        assert!(stats.estimated_cow_clone_bytes > 0);
20669        drop(held);
20670    }
20671}
20672
20673#[cfg(test)]
20674mod trigger_engine_tests {
20675    use super::*;
20676
20677    fn event_with(new_cells: &[(u16, Value)], old_cells: &[(u16, Value)]) -> WriteEvent {
20678        WriteEvent {
20679            table: "test".into(),
20680            kind: TriggerEvent::Insert,
20681            new: Some(TriggerRowImage {
20682                columns: new_cells.iter().cloned().collect(),
20683            }),
20684            old: Some(TriggerRowImage {
20685                columns: old_cells.iter().cloned().collect(),
20686            }),
20687            changed_columns: Vec::new(),
20688            op_indices: Vec::new(),
20689            put_idx: None,
20690            trigger_stack: Vec::new(),
20691        }
20692    }
20693
20694    fn event_insert(new_cells: &[(u16, Value)]) -> WriteEvent {
20695        WriteEvent {
20696            table: "test".into(),
20697            kind: TriggerEvent::Insert,
20698            new: Some(TriggerRowImage {
20699                columns: new_cells.iter().cloned().collect(),
20700            }),
20701            old: None,
20702            changed_columns: Vec::new(),
20703            op_indices: Vec::new(),
20704            put_idx: None,
20705            trigger_stack: Vec::new(),
20706        }
20707    }
20708
20709    #[test]
20710    fn value_order_int64_vs_float64() {
20711        assert_eq!(
20712            value_order(&Value::Int64(5), &Value::Float64(5.0)),
20713            Some(std::cmp::Ordering::Equal)
20714        );
20715        assert_eq!(
20716            value_order(&Value::Int64(5), &Value::Float64(3.0)),
20717            Some(std::cmp::Ordering::Greater)
20718        );
20719        assert_eq!(
20720            value_order(&Value::Int64(2), &Value::Float64(3.0)),
20721            Some(std::cmp::Ordering::Less)
20722        );
20723    }
20724
20725    #[test]
20726    fn value_order_null_returns_none() {
20727        assert_eq!(value_order(&Value::Int64(5), &Value::Null), None);
20728        assert_eq!(value_order(&Value::Null, &Value::Int64(5)), None);
20729        assert_eq!(value_order(&Value::Null, &Value::Null), None);
20730    }
20731
20732    #[test]
20733    fn value_order_cross_group_returns_none() {
20734        assert_eq!(
20735            value_order(&Value::Int64(5), &Value::Bytes(b"x".to_vec())),
20736            None
20737        );
20738        assert_eq!(value_order(&Value::Bool(true), &Value::Int64(1)), None);
20739        assert_eq!(
20740            value_order(
20741                &Value::Embedding(vec![1.0, 2.0]),
20742                &Value::Embedding(vec![1.0, 2.0])
20743            ),
20744            None
20745        );
20746    }
20747
20748    #[test]
20749    fn eval_trigger_expr_ranges_and_booleans() {
20750        let expr = TriggerExpr::And {
20751            left: Box::new(TriggerExpr::Gt {
20752                left: TriggerValue::NewColumn(1),
20753                right: TriggerValue::Literal(Value::Int64(0)),
20754            }),
20755            right: Box::new(TriggerExpr::Lte {
20756                left: TriggerValue::NewColumn(1),
20757                right: TriggerValue::Literal(Value::Int64(100)),
20758            }),
20759        };
20760        assert!(eval_trigger_expr(&expr, &event_insert(&[(1, Value::Int64(50))])).unwrap());
20761        assert!(!eval_trigger_expr(&expr, &event_insert(&[(1, Value::Int64(200))])).unwrap());
20762        assert!(!eval_trigger_expr(&expr, &event_insert(&[(1, Value::Null)])).unwrap());
20763
20764        let or_expr = TriggerExpr::Or {
20765            left: Box::new(TriggerExpr::Lt {
20766                left: TriggerValue::NewColumn(1),
20767                right: TriggerValue::Literal(Value::Int64(0)),
20768            }),
20769            right: Box::new(TriggerExpr::Not(Box::new(TriggerExpr::IsNull(
20770                TriggerValue::OldColumn(2),
20771            )))),
20772        };
20773        assert!(eval_trigger_expr(
20774            &or_expr,
20775            &event_with(&[(1, Value::Int64(5))], &[(2, Value::Int64(99))])
20776        )
20777        .unwrap());
20778        assert!(!eval_trigger_expr(
20779            &or_expr,
20780            &event_with(&[(1, Value::Int64(5))], &[(2, Value::Null)])
20781        )
20782        .unwrap());
20783
20784        assert!(eval_trigger_expr(
20785            &TriggerExpr::Value(TriggerValue::Literal(Value::Bool(true))),
20786            &event_insert(&[])
20787        )
20788        .unwrap());
20789        assert!(!eval_trigger_expr(
20790            &TriggerExpr::Value(TriggerValue::Literal(Value::Bool(false))),
20791            &event_insert(&[])
20792        )
20793        .unwrap());
20794        assert!(!eval_trigger_expr(
20795            &TriggerExpr::Value(TriggerValue::Literal(Value::Null)),
20796            &event_insert(&[])
20797        )
20798        .unwrap());
20799    }
20800}
20801
20802#[cfg(test)]
20803mod core_resource_tests {
20804    use super::*;
20805
20806    fn int_pk_schema() -> Schema {
20807        Schema {
20808            columns: vec![ColumnDef {
20809                id: 1,
20810                name: "id".into(),
20811                ty: TypeId::Int64,
20812                flags: crate::schema::ColumnFlags::empty()
20813                    .with(crate::schema::ColumnFlags::PRIMARY_KEY),
20814                default_value: None,
20815                embedding_source: None,
20816            }],
20817            ..Schema::default()
20818        }
20819    }
20820
20821    #[test]
20822    fn open_constructs_governor_spill_and_jobs_with_documented_defaults() {
20823        let dir = tempfile::tempdir().unwrap();
20824        let db = Database::create(dir.path()).unwrap();
20825        assert_eq!(
20826            db.memory_governor().max_bytes(),
20827            DEFAULT_MEMORY_BUDGET_BYTES
20828        );
20829        assert_eq!(
20830            db.spill_manager().config().global_bytes,
20831            DEFAULT_TEMP_DISK_BUDGET_BYTES
20832        );
20833        assert!(db.job_registry().list().is_empty());
20834        // S1E-002: class defaults seeded at open; no external embedding vendor.
20835        assert_eq!(
20836            db.resource_groups().len(),
20837            crate::resource::WorkloadClass::ALL.len()
20838        );
20839        assert!(db.resource_groups().get("control").is_some());
20840        assert!(db.embedding_providers().list_ids().is_empty());
20841        // Application-supplied path refuses generation (no silent hashed vectors).
20842        let err = db
20843            .embedding_providers()
20844            .embed(
20845                &crate::embedding::EmbeddingSource::SuppliedByApplication,
20846                &["text"],
20847                4,
20848            )
20849            .unwrap_err();
20850        assert!(matches!(
20851            err,
20852            crate::embedding::EmbeddingError::SuppliedByApplication
20853        ));
20854    }
20855
20856    #[test]
20857    fn lock_rows_for_update_acquires_exclusive_row_locks() {
20858        use crate::locks::LockKey;
20859        use crate::rowid::RowId;
20860
20861        let dir = tempfile::tempdir().unwrap();
20862        let db = Database::create(dir.path()).unwrap();
20863        let txn_id = db.allocate_lock_txn_id().unwrap();
20864        let rid = RowId(42);
20865        db.lock_rows_for_update(txn_id, 7, &[rid], None).unwrap();
20866        assert!(db.lock_manager().holds(txn_id, &LockKey::row(7, rid)));
20867        db.release_txn_locks(txn_id);
20868        assert!(!db.lock_manager().holds(txn_id, &LockKey::row(7, rid)));
20869    }
20870
20871    #[test]
20872    fn open_with_options_sizes_the_core_budgets() {
20873        let dir = tempfile::tempdir().unwrap();
20874        let db = Database::create(dir.path()).unwrap();
20875        drop(db);
20876        let db = Database::open_with_options(
20877            dir.path(),
20878            OpenOptions::default()
20879                .with_memory_budget_bytes(256 * 1024 * 1024)
20880                .with_temp_disk_budget_bytes(16 * 1024 * 1024),
20881        )
20882        .unwrap();
20883        assert_eq!(db.memory_governor().max_bytes(), 256 * 1024 * 1024);
20884        assert_eq!(db.spill_manager().config().global_bytes, 16 * 1024 * 1024);
20885    }
20886
20887    #[test]
20888    fn zero_budgets_are_rejected() {
20889        let dir = tempfile::tempdir().unwrap();
20890        let db = Database::create(dir.path()).unwrap();
20891        drop(db);
20892        let result = Database::open_with_options(
20893            dir.path(),
20894            OpenOptions::default().with_memory_budget_bytes(0),
20895        );
20896        assert!(matches!(result, Err(MongrelError::InvalidArgument(_))));
20897        let result = Database::open_with_options(
20898            dir.path(),
20899            OpenOptions::default().with_temp_disk_budget_bytes(0),
20900        );
20901        assert!(matches!(result, Err(MongrelError::InvalidArgument(_))));
20902    }
20903
20904    #[test]
20905    fn page_caches_reserve_under_the_governor() {
20906        let dir = tempfile::tempdir().unwrap();
20907        let db = Database::create(dir.path()).unwrap();
20908        db.create_table("t", int_pk_schema()).unwrap();
20909        let mut txn = db.begin();
20910        txn.put("t", vec![(1, Value::Int64(7))]).unwrap();
20911        txn.commit().unwrap();
20912        // S1E-003: both caches hold reservations under the core's governor, so
20913        // the governor's per-class accounting mirrors their live bytes, and
20914        // both are registered as reclaimable subsystems it can evict.
20915        let stats = db.memory_governor().stats();
20916        assert_eq!(
20917            stats.usage_for(crate::memory::MemoryClass::PageCache),
20918            db.page_cache.used_bytes()
20919        );
20920        assert_eq!(
20921            stats.usage_for(crate::memory::MemoryClass::DecodedCache),
20922            db.decoded_cache.used_bytes()
20923        );
20924        assert_eq!(
20925            db.memory_governor().reclaimable_bytes(),
20926            db.page_cache.used_bytes() + db.decoded_cache.used_bytes()
20927        );
20928        // Driving an eviction through the governor is safe at any level.
20929        let _ = db.memory_governor().evict_reclaimable(1024 * 1024);
20930    }
20931
20932    #[test]
20933    fn job_registry_persists_across_reopen() {
20934        let dir = tempfile::tempdir().unwrap();
20935        let db = Database::create(dir.path()).unwrap();
20936        db.create_table("t", int_pk_schema()).unwrap();
20937        let job_id = db
20938            .job_registry()
20939            .submit(
20940                crate::jobs::JobKind::IndexBuild,
20941                crate::jobs::JobTarget {
20942                    table: "t".to_string(),
20943                    index: Some("idx".to_string()),
20944                },
20945            )
20946            .unwrap();
20947        drop(db);
20948        let db = Database::open(dir.path()).unwrap();
20949        let record = db.job_registry().get(job_id).expect("job survives reopen");
20950        assert_eq!(record.state, crate::jobs::JobState::Pending);
20951    }
20952
20953    #[test]
20954    fn spill_manager_open_sweeps_stale_temp_tree() {
20955        let dir = tempfile::tempdir().unwrap();
20956        let db = Database::create(dir.path()).unwrap();
20957        let stale = dir.path().join("temp").join("spill").join("q-deadbeef");
20958        std::fs::create_dir_all(&stale).unwrap();
20959        std::fs::write(stale.join("chunk-0"), b"stale").unwrap();
20960        drop(db);
20961        let db = Database::open(dir.path()).unwrap();
20962        assert!(
20963            !stale.exists(),
20964            "the startup sweep removes stale spill files (S1E-004)"
20965        );
20966        // A fresh session can start against the swept manager.
20967        let session = db
20968            .spill_manager()
20969            .begin_query(
20970                mongreldb_types::ids::QueryId::from_bytes([7u8; 16]),
20971                1024 * 1024,
20972            )
20973            .unwrap();
20974        assert_eq!(session.used(), 0);
20975    }
20976}
20977
20978#[cfg(test)]
20979mod version_pin_tests {
20980    use super::*;
20981
20982    fn int_pk_schema() -> Schema {
20983        Schema {
20984            columns: vec![ColumnDef {
20985                id: 1,
20986                name: "id".into(),
20987                ty: TypeId::Int64,
20988                flags: crate::schema::ColumnFlags::empty()
20989                    .with(crate::schema::ColumnFlags::PRIMARY_KEY),
20990                default_value: None,
20991                embedding_source: None,
20992            }],
20993            ..Schema::default()
20994        }
20995    }
20996
20997    fn pins_for(
20998        report: &[TablePinsReport],
20999        table: &str,
21000        source: crate::retention::PinSource,
21001    ) -> Option<crate::retention::PinInfo> {
21002        report
21003            .iter()
21004            .find(|entry| entry.table == table)
21005            .and_then(|entry| entry.pins.get(source).cloned())
21006    }
21007
21008    #[test]
21009    fn backup_boundary_registers_backup_pitr_pin() {
21010        let source = tempfile::tempdir().unwrap();
21011        let destination_parent = tempfile::tempdir().unwrap();
21012        let destination = destination_parent.path().join("backup");
21013        let db = Arc::new(Database::create(source.path()).unwrap());
21014        db.create_table("t", int_pk_schema()).unwrap();
21015        let mut txn = db.begin();
21016        txn.put("t", vec![(1, Value::Int64(1))]).unwrap();
21017        let boundary_epoch = txn.commit().unwrap();
21018
21019        let hold = Arc::new(std::sync::Barrier::new(2));
21020        let resume = Arc::new(std::sync::Barrier::new(2));
21021        db.__set_backup_hook({
21022            let hold = Arc::clone(&hold);
21023            let resume = Arc::clone(&resume);
21024            move || {
21025                hold.wait();
21026                resume.wait();
21027            }
21028        });
21029
21030        let backup = {
21031            let db = Arc::clone(&db);
21032            let destination = destination.clone();
21033            std::thread::spawn(move || db.hot_backup(destination))
21034        };
21035        hold.wait();
21036        // The hook fires while the backup's pins are held: the boundary must
21037        // show up as a BackupPitr pin on the table's unified registry.
21038        let report = db.version_pins_report();
21039        let pin = pins_for(&report, "t", crate::retention::PinSource::BackupPitr)
21040            .expect("backup boundary must register a BackupPitr pin");
21041        assert_eq!(pin.oldest_epoch, boundary_epoch);
21042        assert!(pin.pin_count >= 1);
21043        resume.wait();
21044        backup.join().unwrap().unwrap();
21045
21046        let report = db.version_pins_report();
21047        assert!(
21048            pins_for(&report, "t", crate::retention::PinSource::BackupPitr).is_none(),
21049            "the BackupPitr pin releases when the backup finishes"
21050        );
21051    }
21052
21053    #[test]
21054    fn snapshot_and_read_generation_pins_surface_in_report() {
21055        let dir = tempfile::tempdir().unwrap();
21056        let db = Database::create(dir.path()).unwrap();
21057        db.create_table("t", int_pk_schema()).unwrap();
21058        let mut txn = db.begin();
21059        txn.put("t", vec![(1, Value::Int64(7))]).unwrap();
21060        txn.commit().unwrap();
21061
21062        let (_snapshot, guard) = db.snapshot();
21063        let report = db.version_pins_report();
21064        assert!(
21065            pins_for(
21066                &report,
21067                "t",
21068                crate::retention::PinSource::TransactionSnapshot
21069            )
21070            .is_some(),
21071            "a database snapshot projects the TransactionSnapshot source"
21072        );
21073        drop(guard);
21074
21075        let handle = db.table("t").unwrap();
21076        let (generation, _snapshot) = handle.read_generation_with_context(None).unwrap();
21077        let report = db.version_pins_report();
21078        assert!(
21079            pins_for(&report, "t", crate::retention::PinSource::ReadGeneration).is_some(),
21080            "a cloned read generation registers a ReadGeneration pin"
21081        );
21082        drop(generation);
21083
21084        let report = db.version_pins_report();
21085        let entry = report.iter().find(|entry| entry.table == "t").unwrap();
21086        assert!(
21087            entry
21088                .pins
21089                .get(crate::retention::PinSource::BackupPitr)
21090                .is_none()
21091                && entry
21092                    .pins
21093                    .get(crate::retention::PinSource::Replication)
21094                    .is_none()
21095                && entry
21096                    .pins
21097                    .get(crate::retention::PinSource::OnlineIndexBuild)
21098                    .is_none(),
21099            "untaken sources stay absent from the report"
21100        );
21101    }
21102}
21103
21104#[cfg(test)]
21105mod lock_manager_tests {
21106    use super::*;
21107    use crate::locks::LockKey;
21108
21109    fn col(id: u16, name: &str, ty: TypeId, flags: crate::schema::ColumnFlags) -> ColumnDef {
21110        ColumnDef {
21111            id,
21112            name: name.into(),
21113            ty,
21114            flags,
21115            default_value: None,
21116            embedding_source: None,
21117        }
21118    }
21119
21120    fn unique_schema() -> Schema {
21121        let mut constraints = crate::constraint::TableConstraints::default();
21122        constraints
21123            .uniques
21124            .push(crate::constraint::UniqueConstraint {
21125                id: 1,
21126                name: "users_email_unique".into(),
21127                columns: vec![1],
21128            });
21129        Schema {
21130            columns: vec![
21131                col(
21132                    0,
21133                    "id",
21134                    TypeId::Int64,
21135                    crate::schema::ColumnFlags::empty()
21136                        .with(crate::schema::ColumnFlags::PRIMARY_KEY),
21137                ),
21138                col(
21139                    1,
21140                    "email",
21141                    TypeId::Bytes,
21142                    crate::schema::ColumnFlags::empty().with(crate::schema::ColumnFlags::NULLABLE),
21143                ),
21144            ],
21145            constraints,
21146            ..Schema::default()
21147        }
21148    }
21149
21150    fn parent_schema() -> Schema {
21151        Schema {
21152            columns: vec![col(
21153                0,
21154                "id",
21155                TypeId::Int64,
21156                crate::schema::ColumnFlags::empty().with(crate::schema::ColumnFlags::PRIMARY_KEY),
21157            )],
21158            ..Schema::default()
21159        }
21160    }
21161
21162    fn child_schema() -> Schema {
21163        let mut constraints = crate::constraint::TableConstraints::default();
21164        constraints
21165            .foreign_keys
21166            .push(crate::constraint::ForeignKey {
21167                id: 1,
21168                name: "child_parent_fk".into(),
21169                columns: vec![1],
21170                ref_table: "parent".into(),
21171                ref_columns: vec![0],
21172                on_delete: crate::constraint::FkAction::Restrict,
21173                on_update: crate::constraint::FkAction::Restrict,
21174            });
21175        Schema {
21176            columns: vec![
21177                col(
21178                    0,
21179                    "id",
21180                    TypeId::Int64,
21181                    crate::schema::ColumnFlags::empty()
21182                        .with(crate::schema::ColumnFlags::PRIMARY_KEY),
21183                ),
21184                col(
21185                    1,
21186                    "pid",
21187                    TypeId::Int64,
21188                    crate::schema::ColumnFlags::empty().with(crate::schema::ColumnFlags::NULLABLE),
21189                ),
21190            ],
21191            constraints,
21192            ..Schema::default()
21193        }
21194    }
21195
21196    fn auto_inc_schema() -> Schema {
21197        Schema {
21198            columns: vec![col(
21199                0,
21200                "id",
21201                TypeId::Int64,
21202                crate::schema::ColumnFlags::empty()
21203                    .with(crate::schema::ColumnFlags::PRIMARY_KEY)
21204                    .with(crate::schema::ColumnFlags::AUTO_INCREMENT),
21205            )],
21206            ..Schema::default()
21207        }
21208    }
21209
21210    fn pk_lock_key(table_id: u64, value: i64) -> LockKey {
21211        let mut key = b"pk:".to_vec();
21212        key.extend_from_slice(&Value::Int64(value).encode_key());
21213        LockKey::key(table_id, key)
21214    }
21215
21216    #[test]
21217    fn unique_claims_serialize_concurrent_commits() {
21218        let dir = tempfile::tempdir().unwrap();
21219        let db = Arc::new(Database::create(dir.path()).unwrap());
21220        let table_id = db.create_table("users", unique_schema()).unwrap();
21221        let pk_key = pk_lock_key(table_id, 1);
21222        let entered = Arc::new(std::sync::Barrier::new(2));
21223        let resume = Arc::new(std::sync::Barrier::new(2));
21224        let parked = Arc::new(AtomicBool::new(false));
21225        db.__set_catalog_commit_hook({
21226            let entered = Arc::clone(&entered);
21227            let resume = Arc::clone(&resume);
21228            let parked = Arc::clone(&parked);
21229            move || {
21230                // Park only the first commit to reach the sequencer; later
21231                // commits pass straight through.
21232                if !parked.swap(true, Ordering::SeqCst) {
21233                    entered.wait();
21234                    resume.wait();
21235                }
21236            }
21237        });
21238
21239        let mut txn_a = db.begin();
21240        txn_a
21241            .put(
21242                "users",
21243                vec![(0, Value::Int64(1)), (1, Value::Bytes(b"a@x".to_vec()))],
21244            )
21245            .unwrap();
21246        let a_id = txn_a.txn_id();
21247        let (a_tx, a_rx) = std::sync::mpsc::channel();
21248        let (b_tx, b_rx) = std::sync::mpsc::channel();
21249        std::thread::scope(|scope| {
21250            scope.spawn(|| {
21251                a_tx.send(txn_a.commit()).unwrap();
21252            });
21253            entered.wait();
21254            // A is parked in the sequencer holding its unique claims.
21255            assert!(
21256                db.lock_manager().holds(a_id, &pk_key),
21257                "primary-key claim must be held until the commit ends"
21258            );
21259            let mut uq_key = format!("uq{}:", 1).into_bytes();
21260            let cells_map: HashMap<u16, Value> = [(1u16, Value::Bytes(b"a@x".to_vec()))]
21261                .into_iter()
21262                .collect();
21263            uq_key.extend_from_slice(
21264                &crate::constraint::encode_composite_key(&[1], &cells_map).unwrap(),
21265            );
21266            assert!(
21267                db.lock_manager()
21268                    .holds(a_id, &LockKey::key(table_id, uq_key)),
21269                "declared-unique claim must be held until the commit ends"
21270            );
21271
21272            let mut txn_b = db.begin();
21273            txn_b
21274                .put(
21275                    "users",
21276                    vec![(0, Value::Int64(1)), (1, Value::Bytes(b"b@x".to_vec()))],
21277                )
21278                .unwrap();
21279            scope.spawn(|| {
21280                b_tx.send(txn_b.commit()).unwrap();
21281            });
21282            std::thread::sleep(std::time::Duration::from_millis(100));
21283            assert!(
21284                b_rx.try_recv().is_err(),
21285                "the concurrent claim must block until A ends its transaction"
21286            );
21287            resume.wait();
21288            assert!(a_rx.recv().unwrap().is_ok());
21289            let b_result = b_rx.recv().unwrap();
21290            assert!(
21291                matches!(b_result, Err(MongrelError::Conflict(_))),
21292                "the loser surfaces a conflict after serializing: {b_result:?}"
21293            );
21294        });
21295        assert!(
21296            !db.lock_manager().holds(a_id, &pk_key),
21297            "no phantom holds remain after the commit"
21298        );
21299    }
21300
21301    #[test]
21302    fn ddl_waits_for_inflight_dml_commit_on_schema_barrier() {
21303        let dir = tempfile::tempdir().unwrap();
21304        let db = Arc::new(Database::create(dir.path()).unwrap());
21305        db.create_table("parent", parent_schema()).unwrap();
21306        db.create_table("child", child_schema()).unwrap();
21307        let mut seed = db.begin();
21308        seed.put("parent", vec![(0, Value::Int64(1))]).unwrap();
21309        seed.commit().unwrap();
21310
21311        let entered = Arc::new(std::sync::Barrier::new(2));
21312        let resume = Arc::new(std::sync::Barrier::new(2));
21313        db.__set_fk_lock_hook({
21314            let entered = Arc::clone(&entered);
21315            let resume = Arc::clone(&resume);
21316            move || {
21317                entered.wait();
21318                resume.wait();
21319            }
21320        });
21321
21322        let mut txn_a = db.begin();
21323        txn_a
21324            .put("child", vec![(0, Value::Int64(100)), (1, Value::Int64(1))])
21325            .unwrap();
21326        let a_id = txn_a.txn_id();
21327        let (a_tx, a_rx) = std::sync::mpsc::channel();
21328        let (ddl_tx, ddl_rx) = std::sync::mpsc::channel();
21329        std::thread::scope(|scope| {
21330            scope.spawn(|| {
21331                a_tx.send(txn_a.commit()).unwrap();
21332            });
21333            entered.wait();
21334            // A is parked mid-validation: schema barrier held Shared.
21335            assert!(
21336                db.lock_manager().holds(a_id, &LockKey::schema_barrier()),
21337                "DML holds the schema barrier Shared for its commit"
21338            );
21339            let db = Arc::clone(&db);
21340            scope.spawn(move || {
21341                ddl_tx.send(db.drop_table("parent")).unwrap();
21342            });
21343            std::thread::sleep(std::time::Duration::from_millis(100));
21344            assert!(
21345                ddl_rx.try_recv().is_err(),
21346                "DDL must wait on the Exclusive schema barrier while DML is in flight"
21347            );
21348            resume.wait();
21349            // A now finishes and the waiting DDL proceeds. A's commit may
21350            // legitimately lose the publish race against the DDL's security
21351            // version advance — the designed "security policy changed during
21352            // write" outcome — or win it; the barrier guarantees only that the
21353            // DDL could not proceed before A released it.
21354            let a_result = a_rx.recv().unwrap();
21355            match &a_result {
21356                Ok(_) => {}
21357                Err(MongrelError::Conflict(message)) => {
21358                    assert!(
21359                        message.contains("security policy changed during write"),
21360                        "unexpected commit conflict: {message}"
21361                    );
21362                }
21363                other => panic!("unexpected commit outcome: {other:?}"),
21364            }
21365            assert!(ddl_rx.recv().unwrap().is_ok());
21366        });
21367        assert!(!db.lock_manager().holds(a_id, &LockKey::schema_barrier()));
21368    }
21369
21370    #[test]
21371    fn auto_increment_sequence_barrier_held_until_commit() {
21372        let dir = tempfile::tempdir().unwrap();
21373        let db = Database::create(dir.path()).unwrap();
21374        let table_id = db.create_table("seq_t", auto_inc_schema()).unwrap();
21375        let barrier_key = LockKey::sequence_barrier(format!("auto_inc:{table_id}").as_str());
21376        let entered = Arc::new(std::sync::Barrier::new(2));
21377        let resume = Arc::new(std::sync::Barrier::new(2));
21378        let parked = Arc::new(AtomicBool::new(false));
21379        db.__set_catalog_commit_hook({
21380            let entered = Arc::clone(&entered);
21381            let resume = Arc::clone(&resume);
21382            let parked = Arc::clone(&parked);
21383            move || {
21384                if !parked.swap(true, Ordering::SeqCst) {
21385                    entered.wait();
21386                    resume.wait();
21387                }
21388            }
21389        });
21390
21391        let mut txn_a = db.begin();
21392        txn_a.put("seq_t", vec![(0, Value::Null)]).unwrap();
21393        let a_id = txn_a.txn_id();
21394        // The stage-time allocation already holds the barrier.
21395        assert!(
21396            db.lock_manager().holds(a_id, &barrier_key),
21397            "sequence allocation takes the barrier at stage time"
21398        );
21399        let (a_tx, a_rx) = std::sync::mpsc::channel();
21400        std::thread::scope(|scope| {
21401            scope.spawn(|| {
21402                a_tx.send(txn_a.commit()).unwrap();
21403            });
21404            entered.wait();
21405            assert!(
21406                db.lock_manager().holds(a_id, &barrier_key),
21407                "the barrier is held through the commit"
21408            );
21409            resume.wait();
21410            assert!(a_rx.recv().unwrap().is_ok());
21411        });
21412        assert!(
21413            !db.lock_manager().holds(a_id, &barrier_key),
21414            "the barrier releases when the commit ends"
21415        );
21416    }
21417
21418    #[test]
21419    fn fk_wait_for_cycle_surfaces_deadlock_victim() {
21420        let dir = tempfile::tempdir().unwrap();
21421        let db = Database::create(dir.path()).unwrap();
21422        db.create_table("parent", parent_schema()).unwrap();
21423        db.create_table("child", child_schema()).unwrap();
21424        let mut seed = db.begin();
21425        seed.put("parent", vec![(0, Value::Int64(1))]).unwrap();
21426        seed.put("parent", vec![(0, Value::Int64(2))]).unwrap();
21427        seed.commit().unwrap();
21428        let (rid1, rid2) = {
21429            let handle = db.table("parent").unwrap();
21430            let table = handle.lock();
21431            let rid = |pk: i64| {
21432                table
21433                    .lookup_pk(&Value::Int64(pk).encode_key())
21434                    .expect("seeded parent row")
21435            };
21436            (rid(1), rid(2))
21437        };
21438
21439        // The hook fires after every successful FK lock acquisition; park the
21440        // first two (A's and B's Exclusive delete-side claims) so both are
21441        // held before either transaction attempts its Shared insert-side
21442        // claim — a deterministic wait-for cycle.
21443        let rendezvous = Arc::new(std::sync::Barrier::new(2));
21444        let calls = Arc::new(AtomicUsize::new(0));
21445        db.__set_fk_lock_hook({
21446            let rendezvous = Arc::clone(&rendezvous);
21447            let calls = Arc::clone(&calls);
21448            move || {
21449                if calls.fetch_add(1, Ordering::SeqCst) < 2 {
21450                    rendezvous.wait();
21451                }
21452            }
21453        });
21454
21455        // A: delete parent 1 (X fk:1), insert child → parent 2 (S fk:2).
21456        // B: delete parent 2 (X fk:2), insert child → parent 1 (S fk:1).
21457        let mut txn_a = db.begin();
21458        txn_a.delete("parent", rid1).unwrap();
21459        txn_a
21460            .put("child", vec![(0, Value::Int64(100)), (1, Value::Int64(2))])
21461            .unwrap();
21462        let mut txn_b = db.begin();
21463        txn_b.delete("parent", rid2).unwrap();
21464        txn_b
21465            .put("child", vec![(0, Value::Int64(101)), (1, Value::Int64(1))])
21466            .unwrap();
21467        let b_id = txn_b.txn_id();
21468
21469        let (a_tx, a_rx) = std::sync::mpsc::channel();
21470        let (b_tx, b_rx) = std::sync::mpsc::channel();
21471        std::thread::scope(|scope| {
21472            scope.spawn(|| {
21473                a_tx.send(txn_a.commit()).unwrap();
21474            });
21475            scope.spawn(|| {
21476                b_tx.send(txn_b.commit()).unwrap();
21477            });
21478            let a_result = a_rx.recv().unwrap();
21479            let b_result = b_rx.recv().unwrap();
21480            assert!(
21481                a_result.is_ok(),
21482                "the survivor commits once the victim releases: {a_result:?}"
21483            );
21484            match b_result {
21485                Err(MongrelError::Deadlock { victim, .. }) => {
21486                    assert_eq!(victim, b_id, "the youngest transaction is the victim");
21487                }
21488                other => panic!("the victim must surface a deadlock, got {other:?}"),
21489            }
21490        });
21491        // No phantom holds survive the victim's release.
21492        let fk_key = |table: &str, pk: i64| {
21493            let table_id = db.table_id(table).unwrap();
21494            let mut key = b"fk:".to_vec();
21495            key.extend_from_slice(&Value::Int64(pk).encode_key());
21496            LockKey::key(table_id, key)
21497        };
21498        assert!(!db.lock_manager().holds(b_id, &fk_key("parent", 2)));
21499        assert!(!db.lock_manager().holds(b_id, &fk_key("parent", 1)));
21500    }
21501
21502    #[test]
21503    fn locks_release_after_commit_rollback_and_failed_commit() {
21504        let dir = tempfile::tempdir().unwrap();
21505        let db = Database::create(dir.path()).unwrap();
21506        let table_id = db.create_table("seq_t", auto_inc_schema()).unwrap();
21507        let barrier_key = LockKey::sequence_barrier(format!("auto_inc:{table_id}").as_str());
21508
21509        // Successful commit: the stage-time barrier releases with the commit.
21510        let mut txn = db.begin();
21511        txn.put("seq_t", vec![(0, Value::Null)]).unwrap();
21512        let committed_id = txn.txn_id();
21513        txn.commit().unwrap();
21514        assert!(!db.lock_manager().holds(committed_id, &barrier_key));
21515
21516        // Rollback: the stage-time barrier releases with the drop.
21517        let mut txn = db.begin();
21518        txn.put("seq_t", vec![(0, Value::Null)]).unwrap();
21519        let rolled_back_id = txn.txn_id();
21520        assert!(db.lock_manager().holds(rolled_back_id, &barrier_key));
21521        txn.rollback();
21522        assert!(
21523            !db.lock_manager().holds(rolled_back_id, &barrier_key),
21524            "rollback must not leave phantom holds"
21525        );
21526
21527        // Failed commit (declared-unique violation): the claims release with
21528        // the error.
21529        db.create_table("users", unique_schema()).unwrap();
21530        let users_id = db.table_id("users").unwrap();
21531        let mut seed = db.begin();
21532        seed.put(
21533            "users",
21534            vec![(0, Value::Int64(1)), (1, Value::Bytes(b"a@x".to_vec()))],
21535        )
21536        .unwrap();
21537        seed.commit().unwrap();
21538        let mut txn = db.begin();
21539        // A different primary key but the same declared-unique email: the
21540        // Phase B unique check rejects this commit.
21541        txn.put(
21542            "users",
21543            vec![(0, Value::Int64(2)), (1, Value::Bytes(b"a@x".to_vec()))],
21544        )
21545        .unwrap();
21546        let failed_id = txn.txn_id();
21547        let result = txn.commit();
21548        assert!(matches!(result, Err(MongrelError::Conflict(_))));
21549        assert!(
21550            !db.lock_manager()
21551                .holds(failed_id, &pk_lock_key(users_id, 2)),
21552            "a failed commit must not leave phantom holds"
21553        );
21554    }
21555}
21556
21557#[cfg(test)]
21558mod lifecycle_tests {
21559    use super::*;
21560
21561    fn int_pk_schema() -> Schema {
21562        Schema {
21563            columns: vec![ColumnDef {
21564                id: 1,
21565                name: "id".into(),
21566                ty: TypeId::Int64,
21567                flags: crate::schema::ColumnFlags::empty()
21568                    .with(crate::schema::ColumnFlags::PRIMARY_KEY),
21569                default_value: None,
21570                embedding_source: None,
21571            }],
21572            ..Schema::default()
21573        }
21574    }
21575
21576    #[test]
21577    fn poisoned_core_rejects_operations_with_typed_errors() {
21578        let dir = tempfile::tempdir().unwrap();
21579        let db = Database::create(dir.path()).unwrap();
21580        db.create_table("t", int_pk_schema()).unwrap();
21581        assert_eq!(db.lifecycle_state(), crate::core::LifecycleState::Open);
21582
21583        // Drive the exact two-state poison the fsync-error sites set
21584        // (write-path flag + lifecycle transition), without process-global
21585        // fault injection, which would leak into parallel tests. The fsync
21586        // site itself is covered end-to-end in tests/lifecycle_poison.rs.
21587        db.poisoned.store(true, Ordering::Relaxed);
21588        db.lifecycle.poison();
21589        assert_eq!(db.lifecycle_state(), crate::core::LifecycleState::Poisoned);
21590
21591        // Guarded operations without their own write-path poison check reject
21592        // at admission with the lifecycle Conflict...
21593        let error = db.gc().unwrap_err();
21594        assert!(
21595            matches!(error, MongrelError::Conflict(_)),
21596            "gc must reject on a poisoned core: {error:?}"
21597        );
21598        let error = db.compact().unwrap_err();
21599        assert!(
21600            matches!(error, MongrelError::Conflict(_)),
21601            "compact must reject on a poisoned core: {error:?}"
21602        );
21603        assert!(db.operation_guard().is_err());
21604        // ...while paths that already checked the write-path flag keep their
21605        // legacy error.
21606        let error = db.create_table("t2", int_pk_schema()).unwrap_err();
21607        assert!(
21608            error.to_string().contains("database poisoned"),
21609            "the legacy poison error still wins where it existed: {error:?}"
21610        );
21611        let mut txn = db.begin();
21612        txn.put("t", vec![(1, Value::Int64(2))]).unwrap();
21613        assert!(txn
21614            .commit()
21615            .unwrap_err()
21616            .to_string()
21617            .contains("database poisoned"));
21618    }
21619
21620    #[test]
21621    fn shutdown_waits_for_operation_guards_to_drain() {
21622        let dir = tempfile::tempdir().unwrap();
21623        let db = Arc::new(Database::create(dir.path()).unwrap());
21624        db.create_table("t", int_pk_schema()).unwrap();
21625        // The guard holds the lifecycle's Arc, not the database's, so the
21626        // exclusive-owner shutdown can proceed to its drain step below.
21627        let guard = db.operation_guard().unwrap();
21628        let (started_tx, started_rx) = std::sync::mpsc::channel();
21629        let (done_tx, done_rx) = std::sync::mpsc::channel();
21630        let shutdown_thread = std::thread::spawn(move || {
21631            started_tx.send(()).unwrap();
21632            let result = db.shutdown();
21633            let _ = done_tx.send(result);
21634        });
21635        started_rx.recv().unwrap();
21636        std::thread::sleep(std::time::Duration::from_millis(100));
21637        assert!(
21638            done_rx.try_recv().is_err(),
21639            "shutdown must wait for the outstanding guard to drain"
21640        );
21641        drop(guard);
21642        shutdown_thread.join().unwrap();
21643        assert!(
21644            done_rx.recv().unwrap().is_ok(),
21645            "shutdown completes once the guard drops"
21646        );
21647    }
21648}
21649
21650#[cfg(test)]
21651mod commit_ts_ledger_tests {
21652    use super::*;
21653    use crate::memtable::Row;
21654
21655    fn int_pk_schema() -> Schema {
21656        Schema {
21657            columns: vec![ColumnDef {
21658                id: 1,
21659                name: "id".into(),
21660                ty: TypeId::Int64,
21661                flags: crate::schema::ColumnFlags::empty()
21662                    .with(crate::schema::ColumnFlags::PRIMARY_KEY),
21663                default_value: None,
21664                embedding_source: None,
21665            }],
21666            ..Schema::default()
21667        }
21668    }
21669
21670    fn commit_one(db: &Database) -> (Epoch, mongreldb_types::hlc::HlcTimestamp) {
21671        let mut txn = db.begin();
21672        let handle = txn.state_handle();
21673        txn.put("t", vec![(1, Value::Int64(1))]).unwrap();
21674        let epoch = txn.commit().unwrap();
21675        let crate::txn::TransactionState::Committed(receipt) = handle.state() else {
21676            panic!("expected Committed, got {:?}", handle.state());
21677        };
21678        (epoch, receipt.commit_ts)
21679    }
21680
21681    #[test]
21682    fn commit_ts_for_epoch_returns_the_exact_receipt_within_one_open() {
21683        let dir = tempfile::tempdir().unwrap();
21684        let db = Database::create(dir.path()).unwrap();
21685        db.create_table("t", int_pk_schema()).unwrap();
21686
21687        let (epoch, commit_ts) = commit_one(&db);
21688        assert_eq!(db.commit_ts_for_epoch(epoch), Some(commit_ts));
21689        // An epoch no commit sealed misses (callers fall back).
21690        assert_eq!(db.commit_ts_for_epoch(Epoch(epoch.0 + 100)), None);
21691    }
21692
21693    #[test]
21694    fn commit_ts_for_epoch_survives_reopen_with_the_physical_component() {
21695        let dir = tempfile::tempdir().unwrap();
21696        let (epoch, commit_ts) = {
21697            let db = Database::create(dir.path()).unwrap();
21698            db.create_table("t", int_pk_schema()).unwrap();
21699            commit_one(&db)
21700        };
21701
21702        let db = Database::open(dir.path()).unwrap();
21703        let reconstructed = db
21704            .commit_ts_for_epoch(epoch)
21705            .expect("the durable WAL CommitTimestamp ledger reconstructs the epoch");
21706        assert_eq!(reconstructed.physical_micros, commit_ts.physical_micros);
21707        // The ledger byte format stores micros only (spec §8.1): the logical
21708        // counter and tiebreaker reconstruct as 0.
21709        assert_eq!(reconstructed.logical, 0);
21710        assert_eq!(reconstructed.node_tiebreaker, 0);
21711    }
21712
21713    #[test]
21714    fn recovery_ledger_keeps_only_newest_epochs_and_ignores_aborted_txns() {
21715        use crate::wal::Op;
21716        let records = vec![
21717            crate::wal::Record::new(Epoch(1), 7, Op::CommitTimestamp { unix_nanos: 1_000 }),
21718            crate::wal::Record::new(
21719                Epoch(2),
21720                7,
21721                Op::TxnCommit {
21722                    epoch: 41,
21723                    added_runs: vec![],
21724                },
21725            ),
21726            // No CommitTimestamp for txn 8: not reconstructible.
21727            crate::wal::Record::new(
21728                Epoch(3),
21729                8,
21730                Op::TxnCommit {
21731                    epoch: 42,
21732                    added_runs: vec![],
21733                },
21734            ),
21735            // Timestamp without a commit marker: aborted, not reconstructible.
21736            crate::wal::Record::new(Epoch(4), 9, Op::CommitTimestamp { unix_nanos: 9_000 }),
21737        ];
21738        let ledger = commit_ts_ledger_from_recovery(&records);
21739        assert_eq!(ledger.len(), 1);
21740        assert_eq!(
21741            ledger.get(&41),
21742            Some(&mongreldb_types::hlc::HlcTimestamp {
21743                physical_micros: 1,
21744                logical: 0,
21745                node_tiebreaker: 0,
21746            })
21747        );
21748    }
21749
21750    #[test]
21751    fn new_writes_always_have_some_commit_ts() {
21752        let dir = tempfile::tempdir().unwrap();
21753        let db = Database::create(dir.path()).unwrap();
21754        db.create_table("t", int_pk_schema()).unwrap();
21755        let mut txn = db.begin();
21756        let state = txn.state_handle();
21757        txn.put("t", vec![(1, Value::Int64(7))]).unwrap();
21758        let epoch = txn.commit().unwrap();
21759        let crate::txn::TransactionState::Committed(receipt) = state.state() else {
21760            panic!("expected Committed receipt");
21761        };
21762        let handle = db.table("t").unwrap();
21763        let table = handle.lock();
21764        // Product snapshots are HLC-pinned; epoch-only Snapshot::at hides
21765        // HLC-stamped versions by design (no dual authority).
21766        let (snap, _g) = db.snapshot();
21767        let rows = table.visible_rows(snap).expect("visible rows");
21768        assert_eq!(
21769            rows.len(),
21770            1,
21771            "committed put must be visible under HLC snapshot"
21772        );
21773        assert_eq!(rows[0].commit_ts, Some(receipt.commit_ts));
21774        assert_eq!(db.commit_ts_for_epoch(epoch), Some(receipt.commit_ts));
21775    }
21776
21777    #[test]
21778    fn same_transaction_identical_hlc_on_apply() {
21779        use mongreldb_types::ids::{ClusterId, DatabaseId, NodeId};
21780
21781        let dir = tempfile::tempdir().unwrap();
21782        let db = Database::create_cluster_replica(
21783            dir.path(),
21784            ClusterId::from_bytes([1; 16]),
21785            NodeId::from_bytes([2; 16]),
21786            DatabaseId::from_bytes([3; 16]),
21787        )
21788        .unwrap();
21789        db.apply_replicated_catalog_command(&crate::catalog_cmds::CatalogCommandRecord {
21790            version: crate::catalog_cmds::CATALOG_COMMAND_FORMAT_VERSION,
21791            catalog_version: 1,
21792            command: crate::catalog_cmds::CatalogCommand::CreateTable {
21793                name: "t".into(),
21794                schema: int_pk_schema(),
21795                created_epoch: 1,
21796            },
21797        })
21798        .unwrap();
21799        let table_id = db.table_id("t").unwrap();
21800        let commit_ts = mongreldb_types::hlc::HlcTimestamp {
21801            physical_micros: 42_000,
21802            logical: 3,
21803            node_tiebreaker: 9,
21804        };
21805        // Same decision HLC applied twice (two participants / two apply
21806        // calls) must stamp identical commit_ts on every row version.
21807        let staged = vec![StagedTxnWrite::Put {
21808            table_id,
21809            rows: bincode::serialize(&vec![
21810                Row::new(crate::RowId(1), Epoch(0)).with_column(1, Value::Int64(1))
21811            ])
21812            .unwrap(),
21813        }
21814        .encode()
21815        .unwrap()];
21816        assert!(db
21817            .apply_committed_transaction(1 << 63, commit_ts, &staged)
21818            .unwrap());
21819        let staged2 = vec![StagedTxnWrite::Put {
21820            table_id,
21821            rows: bincode::serialize(&vec![
21822                Row::new(crate::RowId(2), Epoch(0)).with_column(1, Value::Int64(2))
21823            ])
21824            .unwrap(),
21825        }
21826        .encode()
21827        .unwrap()];
21828        assert!(db
21829            .apply_committed_transaction((1 << 63) + 1, commit_ts, &staged2)
21830            .unwrap());
21831        let handle = db.table("t").unwrap();
21832        let table = handle.lock();
21833        let row1 = table
21834            .get(crate::RowId(1), Snapshot::unbounded())
21835            .expect("applied row 1");
21836        let row2 = table
21837            .get(crate::RowId(2), Snapshot::unbounded())
21838            .expect("applied row 2");
21839        // The durable WAL `Put` payload does not carry `Row::commit_ts`
21840        // (0.63.1 bincode layout); rows are restamped from the txn's
21841        // `Op::CommitTimestamp` ledger record, which carries physical micros
21842        // only. Both applies must observe that identical recovery form.
21843        let recovery_form = mongreldb_types::hlc::HlcTimestamp {
21844            physical_micros: commit_ts.physical_micros,
21845            logical: 0,
21846            node_tiebreaker: 0,
21847        };
21848        assert_eq!(row1.commit_ts, Some(recovery_form));
21849        assert_eq!(row2.commit_ts, Some(recovery_form));
21850        assert_eq!(row1.commit_ts, row2.commit_ts);
21851        drop(table);
21852        // Latest applied epoch's ledger entry matches the shared decision HLC
21853        // physical component (logical/tiebreaker may be zero on recovery form).
21854        let epoch = db.visible_epoch();
21855        assert_eq!(
21856            db.commit_ts_for_epoch(epoch).map(|ts| ts.physical_micros),
21857            Some(commit_ts.physical_micros)
21858        );
21859    }
21860
21861    #[test]
21862    fn snapshot_hlc_hides_later_commit_ts_even_if_epoch_higher() {
21863        use mongreldb_types::hlc::HlcTimestamp;
21864        let early = HlcTimestamp {
21865            physical_micros: 100,
21866            logical: 0,
21867            node_tiebreaker: 1,
21868        };
21869        let late = HlcTimestamp {
21870            physical_micros: 200,
21871            logical: 0,
21872            node_tiebreaker: 1,
21873        };
21874        // Snapshot at early HLC with a high epoch budget still hides a later HLC.
21875        let snap = Snapshot::at_hlc(Epoch(99), early);
21876        assert!(!snap.observes_version(Epoch(1), Some(late)));
21877        assert!(snap.observes_version(Epoch(1), Some(early)));
21878        // Live Database snapshots are HLC-pinned.
21879        let dir = tempfile::tempdir().unwrap();
21880        let db = Database::create(dir.path()).unwrap();
21881        let (snap, _g) = db.snapshot();
21882        assert_ne!(
21883            snap.commit_ts,
21884            HlcTimestamp::ZERO,
21885            "Database::snapshot must pin live HLC via at_hlc"
21886        );
21887    }
21888
21889    #[test]
21890    fn hlc_stamped_row_visible_at_hlc_snapshot_not_epoch_only() {
21891        let dir = tempfile::tempdir().unwrap();
21892        let db = Database::create(dir.path()).unwrap();
21893        db.create_table("t", int_pk_schema()).unwrap();
21894        let (_epoch, commit_ts) = commit_one(&db);
21895        assert_ne!(commit_ts, mongreldb_types::hlc::HlcTimestamp::ZERO);
21896
21897        let handle = db.table("t").unwrap();
21898        let table = handle.lock();
21899        // (a) HLC-stamped row visible at an HLC-pinned snapshot.
21900        let hlc_snap = Snapshot::at_hlc(Epoch(u64::MAX), commit_ts);
21901        let rows = table.visible_rows(hlc_snap).expect("visible");
21902        assert_eq!(rows.len(), 1);
21903        assert_eq!(rows[0].commit_ts, Some(commit_ts));
21904        assert!(table.get(rows[0].row_id, hlc_snap).is_some());
21905
21906        // (b) Epoch-only snapshot still sees HLC-stamped rows by epoch (dual-model).
21907        let legacy = Snapshot::at(Epoch(u64::MAX));
21908        assert!(!legacy.uses_hlc_authority());
21909        assert_eq!(
21910            table.visible_rows(legacy).expect("visible").len(),
21911            1,
21912            "epoch pin sees HLC-stamped rows by epoch during dual-model migration"
21913        );
21914        assert!(table.get(rows[0].row_id, legacy).is_some());
21915    }
21916
21917    #[test]
21918    fn hlc_gc_floor_reports_named_sources() {
21919        let dir = tempfile::tempdir().unwrap();
21920        let db = Database::create(dir.path()).unwrap();
21921        db.create_table("t", int_pk_schema()).unwrap();
21922        let (epoch, commit_ts) = commit_one(&db);
21923
21924        // No pins: every HLC source is ZERO.
21925        let empty = db.hlc_gc_floor();
21926        assert_eq!(empty.floor(), mongreldb_types::hlc::HlcTimestamp::ZERO);
21927        assert_eq!(empty.sources().len(), 6);
21928
21929        // Pin via product snapshot (transaction source, epoch-backed).
21930        let (_snap, guard) = db.snapshot();
21931        let with_pin = db.hlc_gc_floor();
21932        // Projection succeeds only when the ledger has a stamp for the pin epoch.
21933        let projected = db.commit_ts_for_epoch(epoch);
21934        if let Some(ts) = projected {
21935            // Snapshot pins the *visible* watermark, which should match the commit.
21936            assert_eq!(with_pin.transaction_snapshot, ts);
21937            assert_eq!(with_pin.floor(), ts);
21938            assert_eq!(ts.physical_micros, commit_ts.physical_micros);
21939        } else {
21940            assert_eq!(
21941                with_pin.transaction_snapshot,
21942                mongreldb_types::hlc::HlcTimestamp::ZERO
21943            );
21944        }
21945        drop(guard);
21946    }
21947}
21948
21949#[cfg(test)]
21950mod stage2e_storage_mode_tests {
21951    use super::*;
21952    use crate::schema::{ColumnDef, ColumnFlags, Schema, TypeId};
21953    use crate::storage_mode::{StorageMode, STORAGE_MODE_FILENAME};
21954    use mongreldb_types::ids::{ClusterId, DatabaseId, NodeId};
21955
21956    fn identity(seed: u8) -> (ClusterId, NodeId, DatabaseId) {
21957        (
21958            ClusterId::from_bytes([seed; 16]),
21959            NodeId::from_bytes([seed + 1; 16]),
21960            DatabaseId::from_bytes([seed + 2; 16]),
21961        )
21962    }
21963
21964    fn marker(root: &Path) -> Option<StorageMode> {
21965        let durable = crate::durable_file::DurableRoot::open(root).unwrap();
21966        crate::storage_mode::read(&durable).unwrap()
21967    }
21968
21969    fn simple_schema() -> Schema {
21970        Schema {
21971            columns: vec![ColumnDef {
21972                id: 1,
21973                name: "id".into(),
21974                ty: TypeId::Int64,
21975                flags: ColumnFlags::empty().with(ColumnFlags::PRIMARY_KEY),
21976                default_value: None,
21977                embedding_source: None,
21978            }],
21979            ..Schema::default()
21980        }
21981    }
21982
21983    #[test]
21984    fn standalone_create_writes_marker_and_reopens() {
21985        let dir = tempfile::tempdir().unwrap();
21986        let root = dir.path().join("db");
21987        let db = Database::create(&root).unwrap();
21988        assert_eq!(marker(&root), Some(StorageMode::Standalone));
21989        assert_eq!(db.storage_mode().unwrap(), Some(StorageMode::Standalone));
21990        drop(db);
21991        let db = Database::open(&root).unwrap();
21992        assert_eq!(marker(&root), Some(StorageMode::Standalone));
21993        drop(db);
21994    }
21995
21996    #[test]
21997    fn legacy_database_without_marker_opens_and_gains_marker() {
21998        let dir = tempfile::tempdir().unwrap();
21999        let root = dir.path().join("db");
22000        let db = Database::create(&root).unwrap();
22001        drop(db);
22002        // Simulate a pre-marker database.
22003        std::fs::remove_file(root.join(META_DIR).join(STORAGE_MODE_FILENAME)).unwrap();
22004        assert_eq!(marker(&root), None);
22005        let db = Database::open(&root).unwrap();
22006        assert_eq!(marker(&root), Some(StorageMode::Standalone));
22007        drop(db);
22008    }
22009
22010    #[test]
22011    fn server_owned_standalone_opens_embedded() {
22012        let dir = tempfile::tempdir().unwrap();
22013        let root = dir.path().join("db");
22014        let db = Database::create(&root).unwrap();
22015        drop(db);
22016        let durable = crate::durable_file::DurableRoot::open(&root).unwrap();
22017        crate::storage_mode::rewrite(&durable, &StorageMode::ServerOwnedStandalone).unwrap();
22018        let db = Database::open(&root).unwrap();
22019        assert_eq!(marker(&root), Some(StorageMode::ServerOwnedStandalone));
22020        drop(db);
22021    }
22022
22023    #[test]
22024    fn cluster_replica_is_rejected_by_normal_opens() {
22025        let dir = tempfile::tempdir().unwrap();
22026        let root = dir.path().join("db");
22027        let (cluster_id, node_id, database_id) = identity(10);
22028        let db = Database::create_cluster_replica(&root, cluster_id, node_id, database_id).unwrap();
22029        assert_eq!(
22030            marker(&root),
22031            Some(StorageMode::ClusterReplica {
22032                cluster_id,
22033                node_id,
22034                database_id,
22035            })
22036        );
22037        drop(db);
22038
22039        let error = Database::open(&root).unwrap_err();
22040        let message = error.to_string();
22041        assert!(
22042            matches!(error, MongrelError::InvalidArgument(_)),
22043            "unexpected error: {message}"
22044        );
22045        assert!(message.contains("cluster node runtime"), "{message}");
22046        assert!(message.contains(&cluster_id.to_hex()), "{message}");
22047        assert!(message.contains(&node_id.to_hex()), "{message}");
22048        assert!(message.contains(&database_id.to_hex()), "{message}");
22049
22050        let error = Database::open_with_options(&root, OpenOptions::default()).unwrap_err();
22051        assert!(error.to_string().contains("cluster node runtime"));
22052        // The rejected opens never disturbed the marker.
22053        assert_eq!(
22054            marker(&root),
22055            Some(StorageMode::ClusterReplica {
22056                cluster_id,
22057                node_id,
22058                database_id,
22059            })
22060        );
22061    }
22062
22063    #[test]
22064    fn offline_validation_opens_cluster_replica_read_only() {
22065        let dir = tempfile::tempdir().unwrap();
22066        let root = dir.path().join("db");
22067        let (cluster_id, node_id, database_id) = identity(20);
22068        let db = Database::create_cluster_replica(&root, cluster_id, node_id, database_id).unwrap();
22069        drop(db);
22070
22071        let options = OpenOptions::default().with_offline_validation(true);
22072        let db = Database::open_with_options(&root, options).unwrap();
22073        assert!(db.is_read_only_replica());
22074        let error = db.create_table("t", simple_schema()).unwrap_err();
22075        assert!(matches!(error, MongrelError::ReadOnlyReplica));
22076        drop(db);
22077        // Offline validation leaves the marker exactly as found.
22078        assert_eq!(
22079            marker(&root),
22080            Some(StorageMode::ClusterReplica {
22081                cluster_id,
22082                node_id,
22083                database_id,
22084            })
22085        );
22086    }
22087
22088    #[test]
22089    fn cluster_runtime_open_requires_exact_identity() {
22090        let dir = tempfile::tempdir().unwrap();
22091        let root = dir.path().join("db");
22092        let (cluster_id, node_id, database_id) = identity(30);
22093        let db = Database::create_cluster_replica(&root, cluster_id, node_id, database_id).unwrap();
22094        drop(db);
22095
22096        // A non-ClusterReplica expectation is a caller error.
22097        let error = Database::open_cluster_replica(&root, &StorageMode::Standalone).unwrap_err();
22098        assert!(matches!(error, MongrelError::InvalidArgument(_)));
22099        // Wrong database identity fails closed.
22100        let wrong = StorageMode::ClusterReplica {
22101            cluster_id,
22102            node_id,
22103            database_id: DatabaseId::from_bytes([99; 16]),
22104        };
22105        let error = Database::open_cluster_replica(&root, &wrong).unwrap_err();
22106        assert!(error.to_string().contains("identity mismatch"), "{error}");
22107        // A legacy database without a marker is not a cluster replica.
22108        let legacy = dir.path().join("legacy");
22109        let legacy_db = Database::create(&legacy).unwrap();
22110        drop(legacy_db);
22111        let expected = StorageMode::ClusterReplica {
22112            cluster_id,
22113            node_id,
22114            database_id,
22115        };
22116        let error = Database::open_cluster_replica(&legacy, &expected).unwrap_err();
22117        assert!(error.to_string().contains("identity mismatch"), "{error}");
22118
22119        // The matching identity opens; user writes are rejected (writes
22120        // arrive through the replicated apply path only).
22121        let db = Database::open_cluster_replica(&root, &expected).unwrap();
22122        assert!(db.is_read_only_replica());
22123        let error = db.create_table("t", simple_schema()).unwrap_err();
22124        assert!(matches!(error, MongrelError::ReadOnlyReplica));
22125        drop(db);
22126    }
22127}
22128
22129#[cfg(test)]
22130mod stage2e_replicated_apply_tests {
22131    use super::*;
22132    use crate::catalog_cmds::{CatalogCommand, CatalogCommandRecord, CatalogDelta};
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    use std::sync::Arc;
22138
22139    fn ids() -> (ClusterId, NodeId, DatabaseId) {
22140        (
22141            ClusterId::from_bytes([1; 16]),
22142            NodeId::from_bytes([2; 16]),
22143            DatabaseId::from_bytes([3; 16]),
22144        )
22145    }
22146
22147    fn expected_mode() -> crate::storage_mode::StorageMode {
22148        let (cluster_id, node_id, database_id) = ids();
22149        crate::storage_mode::StorageMode::ClusterReplica {
22150            cluster_id,
22151            node_id,
22152            database_id,
22153        }
22154    }
22155
22156    fn simple_schema() -> Schema {
22157        Schema {
22158            columns: vec![ColumnDef {
22159                id: 1,
22160                name: "id".into(),
22161                ty: TypeId::Int64,
22162                flags: ColumnFlags::empty().with(ColumnFlags::PRIMARY_KEY),
22163                default_value: None,
22164                embedding_source: None,
22165            }],
22166            ..Schema::default()
22167        }
22168    }
22169
22170    fn create_table_record(name: &str, catalog_version: u64) -> CatalogCommandRecord {
22171        CatalogCommandRecord {
22172            version: crate::catalog_cmds::CATALOG_COMMAND_FORMAT_VERSION,
22173            catalog_version,
22174            command: CatalogCommand::CreateTable {
22175                name: name.to_string(),
22176                schema: simple_schema(),
22177                created_epoch: 1,
22178            },
22179        }
22180    }
22181
22182    fn put_records(txn_id: u64, table_id: u64, epoch: u64, values: &[i64]) -> Vec<Record> {
22183        let rows: Vec<Row> = values
22184            .iter()
22185            .map(|value| {
22186                // Distinct row ids per value so batches never overwrite each
22187                // other's MVCC versions.
22188                Row::new(crate::RowId(*value as u64), Epoch(epoch))
22189                    .with_column(1, Value::Int64(*value))
22190            })
22191            .collect();
22192        vec![
22193            Record::new(
22194                Epoch(0),
22195                txn_id,
22196                Op::Put {
22197                    table_id,
22198                    rows: bincode::serialize(&rows).unwrap(),
22199                },
22200            ),
22201            Record::new(Epoch(0), txn_id, Op::CommitTimestamp { unix_nanos: 1_000 }),
22202            Record::new(
22203                Epoch(0),
22204                txn_id,
22205                Op::TxnCommit {
22206                    epoch,
22207                    added_runs: Vec::new(),
22208                },
22209            ),
22210        ]
22211    }
22212
22213    fn visible_ids(db: &Database, table: &str) -> Vec<i64> {
22214        let handle = db.table(table).unwrap();
22215        // P0.5: HLC-stamped versions require an HLC-pinned snapshot.
22216        let snap = db.visible_snapshot();
22217        let rows = handle.lock().visible_rows(snap).unwrap();
22218        let mut values: Vec<i64> = rows
22219            .iter()
22220            .map(|row| match row.columns.get(&1) {
22221                Some(Value::Int64(value)) => *value,
22222                other => panic!("unexpected column: {other:?}"),
22223            })
22224            .collect();
22225        values.sort_unstable();
22226        values
22227    }
22228
22229    #[test]
22230    fn catalog_command_mounts_table_and_replays_as_noop() {
22231        let dir = tempfile::tempdir().unwrap();
22232        let (cluster_id, node_id, database_id) = ids();
22233        let db =
22234            Database::create_cluster_replica(dir.path(), cluster_id, node_id, database_id).unwrap();
22235
22236        let record = create_table_record("items", 1);
22237        let delta = db.apply_replicated_catalog_command(&record).unwrap();
22238        assert!(matches!(delta, CatalogDelta::TableCreated { .. }));
22239        assert_eq!(db.table_names(), vec!["items".to_string()]);
22240        assert_eq!(db.catalog_version(), 1);
22241
22242        // Idempotent replay of the same record.
22243        let delta = db.apply_replicated_catalog_command(&record).unwrap();
22244        assert!(matches!(delta, CatalogDelta::NoOp));
22245        assert_eq!(db.table_names().len(), 1);
22246        drop(db);
22247
22248        // The command was checkpointed: the table survives reopen.
22249        let db = Database::open_cluster_replica(dir.path(), &expected_mode()).unwrap();
22250        assert_eq!(db.table_names(), vec!["items".to_string()]);
22251        assert_eq!(db.catalog_version(), 1);
22252    }
22253
22254    #[test]
22255    fn records_apply_rows_and_skip_replays_across_restart() {
22256        let dir = tempfile::tempdir().unwrap();
22257        let (cluster_id, node_id, database_id) = ids();
22258        let db =
22259            Database::create_cluster_replica(dir.path(), cluster_id, node_id, database_id).unwrap();
22260        db.apply_replicated_catalog_command(&create_table_record("items", 1))
22261            .unwrap();
22262
22263        let records = put_records(1, 0, 2, &[10, 20, 30]);
22264        assert!(db.apply_replicated_records(&records).unwrap());
22265        assert_eq!(visible_ids(&db, "items"), vec![10, 20, 30]);
22266        assert_eq!(db.visible_epoch(), Epoch(2));
22267
22268        // Crash-window redelivery of the same committed transaction is a
22269        // side-effect-free replay.
22270        assert!(!db.apply_replicated_records(&records).unwrap());
22271        assert_eq!(visible_ids(&db, "items"), vec![10, 20, 30]);
22272
22273        // A later transaction at a higher epoch still applies.
22274        let later = put_records(2, 0, 3, &[40]);
22275        assert!(db.apply_replicated_records(&later).unwrap());
22276        assert_eq!(visible_ids(&db, "items"), vec![10, 20, 30, 40]);
22277        let db = Arc::new(db);
22278        db.shutdown().unwrap();
22279
22280        // Restart: the local WAL replays the applied rows, and the state
22281        // machine's redelivery is recognized as a replay — no double-apply.
22282        let db = Database::open_cluster_replica(dir.path(), &expected_mode()).unwrap();
22283        assert_eq!(visible_ids(&db, "items"), vec![10, 20, 30, 40]);
22284        assert!(!db.apply_replicated_records(&later).unwrap());
22285        assert!(!db.apply_replicated_records(&records).unwrap());
22286        assert_eq!(visible_ids(&db, "items"), vec![10, 20, 30, 40]);
22287    }
22288
22289    #[test]
22290    fn spilled_run_commits_fail_closed_this_wave() {
22291        let dir = tempfile::tempdir().unwrap();
22292        let (cluster_id, node_id, database_id) = ids();
22293        let db =
22294            Database::create_cluster_replica(dir.path(), cluster_id, node_id, database_id).unwrap();
22295        db.apply_replicated_catalog_command(&create_table_record("items", 1))
22296            .unwrap();
22297        let mut records = put_records(1, 0, 2, &[10]);
22298        let Some(Op::TxnCommit { added_runs, .. }) = records.last_mut().map(|r| &mut r.op) else {
22299            panic!("put_records ends in TxnCommit");
22300        };
22301        added_runs.push(crate::wal::AddedRun {
22302            table_id: 0,
22303            run_id: 7,
22304            row_count: 1,
22305            level: 0,
22306            min_row_id: 1,
22307            max_row_id: 1,
22308            content_hash: [0; 32],
22309        });
22310        let error = db.apply_replicated_records(&records).unwrap_err();
22311        assert!(
22312            error.to_string().contains("spilled-run"),
22313            "unexpected error: {error}"
22314        );
22315        assert_eq!(visible_ids(&db, "items"), Vec::<i64>::new());
22316    }
22317
22318    #[test]
22319    fn records_without_commit_marker_fail_closed() {
22320        let dir = tempfile::tempdir().unwrap();
22321        let (cluster_id, node_id, database_id) = ids();
22322        let db =
22323            Database::create_cluster_replica(dir.path(), cluster_id, node_id, database_id).unwrap();
22324        db.apply_replicated_catalog_command(&create_table_record("items", 1))
22325            .unwrap();
22326        let mut records = put_records(1, 0, 2, &[10]);
22327        records.pop(); // strip the TxnCommit
22328        let error = db.apply_replicated_records(&records).unwrap_err();
22329        assert!(matches!(error, MongrelError::InvalidArgument(_)));
22330        assert!(db.apply_replicated_records(&[]).is_err());
22331        assert_eq!(visible_ids(&db, "items"), Vec::<i64>::new());
22332    }
22333}
22334
22335#[cfg(test)]
22336mod stage2c_spill_translation_tests {
22337    use super::*;
22338    use crate::catalog_cmds::{CatalogCommand, CatalogCommandRecord};
22339    use crate::memtable::{Row, Value};
22340    use crate::schema::{ColumnDef, ColumnFlags, Schema, TypeId};
22341    use crate::wal::{Op, Record};
22342    use mongreldb_types::ids::{ClusterId, DatabaseId, NodeId};
22343
22344    fn simple_schema() -> Schema {
22345        Schema {
22346            columns: vec![ColumnDef {
22347                id: 1,
22348                name: "id".into(),
22349                ty: TypeId::Int64,
22350                flags: ColumnFlags::empty().with(ColumnFlags::PRIMARY_KEY),
22351                default_value: None,
22352                embedding_source: None,
22353            }],
22354            ..Schema::default()
22355        }
22356    }
22357
22358    fn create_table_record(name: &str, catalog_version: u64) -> CatalogCommandRecord {
22359        CatalogCommandRecord {
22360            version: crate::catalog_cmds::CATALOG_COMMAND_FORMAT_VERSION,
22361            catalog_version,
22362            command: CatalogCommand::CreateTable {
22363                name: name.to_string(),
22364                schema: simple_schema(),
22365                created_epoch: 1,
22366            },
22367        }
22368    }
22369
22370    fn visible_ids(db: &Database, table: &str) -> Vec<i64> {
22371        let handle = db.table(table).unwrap();
22372        // P0.5: HLC-stamped versions require an HLC-pinned snapshot.
22373        let snap = db.visible_snapshot();
22374        let rows = handle.lock().visible_rows(snap).unwrap();
22375        let mut values: Vec<i64> = rows
22376            .iter()
22377            .map(|row| match row.columns.get(&1) {
22378                Some(Value::Int64(value)) => *value,
22379                other => panic!("unexpected column: {other:?}"),
22380            })
22381            .collect();
22382        values.sort_unstable();
22383        values
22384    }
22385
22386    fn put_records(txn_id: u64, table_id: u64, epoch: u64, values: &[i64]) -> Vec<Record> {
22387        let rows: Vec<Row> = values
22388            .iter()
22389            .map(|value| {
22390                Row::new(crate::RowId(*value as u64), Epoch(epoch))
22391                    .with_column(1, Value::Int64(*value))
22392            })
22393            .collect();
22394        vec![
22395            Record::new(
22396                Epoch(0),
22397                txn_id,
22398                Op::Put {
22399                    table_id,
22400                    rows: bincode::serialize(&rows).unwrap(),
22401                },
22402            ),
22403            Record::new(Epoch(0), txn_id, Op::CommitTimestamp { unix_nanos: 1_000 }),
22404            Record::new(
22405                Epoch(0),
22406                txn_id,
22407                Op::TxnCommit {
22408                    epoch,
22409                    added_runs: Vec::new(),
22410                },
22411            ),
22412        ]
22413    }
22414
22415    fn added_run(
22416        table_id: u64,
22417        row_count: u64,
22418        min_row_id: u64,
22419        max_row_id: u64,
22420    ) -> crate::wal::AddedRun {
22421        crate::wal::AddedRun {
22422            table_id,
22423            run_id: 7,
22424            row_count,
22425            level: 0,
22426            min_row_id,
22427            max_row_id,
22428            content_hash: [0; 32],
22429        }
22430    }
22431
22432    /// Replays the shared WAL of `db` and returns every record of the one
22433    /// transaction whose commit marker links spilled runs.
22434    fn spilled_commit_records(db: &Database) -> Vec<Record> {
22435        let records = crate::wal::SharedWal::replay_with_dek(&db.root, None).unwrap();
22436        let txn_id = records
22437            .iter()
22438            .find_map(|record| match &record.op {
22439                Op::TxnCommit { added_runs, .. } if !added_runs.is_empty() => Some(record.txn_id),
22440                _ => None,
22441            })
22442            .expect("a spilled commit is present in the WAL");
22443        records
22444            .into_iter()
22445            .filter(|record| record.txn_id == txn_id)
22446            .collect()
22447    }
22448
22449    #[test]
22450    fn non_spilled_records_translate_byte_identical() {
22451        let records = put_records(1, 0, 2, &[10, 20, 30]);
22452        let translated = translate_records_for_replication(&records).unwrap();
22453        assert_eq!(
22454            bincode::serialize(&translated).unwrap(),
22455            bincode::serialize(&records).unwrap(),
22456            "a commit without spill links must pass through byte-identical"
22457        );
22458    }
22459
22460    #[test]
22461    fn translation_rejects_uncovered_or_malformed_spills() {
22462        // added_runs with no logical spill records at all: rejected.
22463        let mut records = put_records(1, 0, 2, &[10]);
22464        let Some(Op::TxnCommit { added_runs, .. }) = records.last_mut().map(|r| &mut r.op) else {
22465            panic!("put_records ends in TxnCommit");
22466        };
22467        added_runs.push(added_run(0, 1, 10, 10));
22468        let error = translate_records_for_replication(&records).unwrap_err();
22469        assert!(
22470            error.to_string().contains("no logical row records"),
22471            "unexpected error: {error}"
22472        );
22473
22474        // Coverage present but short of the linked row count: rejected.
22475        let mut records = put_records(1, 0, 2, &[10]);
22476        let spilled: Vec<Row> = (0..3_u64)
22477            .map(|value| {
22478                Row::new(crate::RowId(value), Epoch(2)).with_column(1, Value::Int64(value as i64))
22479            })
22480            .collect();
22481        records.insert(
22482            0,
22483            Record::new(
22484                Epoch(0),
22485                1,
22486                Op::SpilledRows {
22487                    table_id: 0,
22488                    rows: bincode::serialize(&spilled).unwrap(),
22489                },
22490            ),
22491        );
22492        let Some(Op::TxnCommit { added_runs, .. }) = records.last_mut().map(|r| &mut r.op) else {
22493            panic!("put_records ends in TxnCommit");
22494        };
22495        added_runs.push(added_run(0, 4, 0, 3));
22496        let error = translate_records_for_replication(&records).unwrap_err();
22497        assert!(
22498            error.to_string().contains("cover 3 rows"),
22499            "unexpected error: {error}"
22500        );
22501
22502        // An undecodable spill payload: rejected at propose time, never at apply.
22503        let mut records = put_records(1, 0, 2, &[10]);
22504        records.insert(
22505            0,
22506            Record::new(
22507                Epoch(0),
22508                1,
22509                Op::SpilledRows {
22510                    table_id: 0,
22511                    rows: vec![0xFF, 0x01, 0x02],
22512                },
22513            ),
22514        );
22515        let Some(Op::TxnCommit { added_runs, .. }) = records.last_mut().map(|r| &mut r.op) else {
22516            panic!("put_records ends in TxnCommit");
22517        };
22518        added_runs.push(added_run(0, 1, 0, 0));
22519        assert!(translate_records_for_replication(&records).is_err());
22520
22521        // Structural violations mirror the apply-side contract.
22522        assert!(translate_records_for_replication(&[]).is_err());
22523        let mut mixed = put_records(1, 0, 2, &[10]);
22524        mixed[0].txn_id = 99;
22525        assert!(translate_records_for_replication(&mixed).is_err());
22526        let mut no_commit = put_records(1, 0, 2, &[10]);
22527        no_commit.pop();
22528        assert!(translate_records_for_replication(&no_commit).is_err());
22529    }
22530
22531    #[test]
22532    fn spilled_commit_translates_to_logical_rows_and_applies_on_replica() {
22533        // A real standalone commit that spills (spec section 8.5).
22534        let leader_dir = tempfile::tempdir().unwrap();
22535        let leader = Database::create(leader_dir.path()).unwrap();
22536        leader.create_table("t", simple_schema()).unwrap();
22537        leader.set_spill_threshold(1);
22538        let table_id = leader.table_id("t").unwrap();
22539        let values: Vec<i64> = (0..60).collect();
22540        leader
22541            .transaction(|txn| {
22542                for value in &values {
22543                    txn.put("t", vec![(1, Value::Int64(*value))])?;
22544                }
22545                Ok(())
22546            })
22547            .unwrap();
22548        assert_eq!(visible_ids(&leader, "t"), values);
22549
22550        // The leader's own WAL keeps the spill shape: SpilledRows records
22551        // plus an added_runs commit marker.
22552        let records = spilled_commit_records(&leader);
22553        assert!(records
22554            .iter()
22555            .any(|record| matches!(record.op, Op::SpilledRows { .. })));
22556        let Some(Op::TxnCommit { added_runs, epoch }) = records.last().map(|r| &r.op) else {
22557            panic!("a commit sequence ends in TxnCommit");
22558        };
22559        assert!(!added_runs.is_empty());
22560        let commit_epoch = *epoch;
22561
22562        // Translation strips every run reference and keeps the rows as
22563        // logical puts; the input sequence is untouched.
22564        let translated = translate_records_for_replication(&records).unwrap();
22565        assert!(records
22566            .iter()
22567            .any(|record| matches!(record.op, Op::SpilledRows { .. })));
22568        let Some(Op::TxnCommit { added_runs, .. }) = records.last().map(|r| &r.op) else {
22569            panic!("a commit sequence ends in TxnCommit");
22570        };
22571        assert!(!added_runs.is_empty(), "input records must be unchanged");
22572        assert_eq!(translated.len(), records.len());
22573        assert!(translated
22574            .iter()
22575            .all(|record| !matches!(record.op, Op::SpilledRows { .. })));
22576        assert!(translated
22577            .iter()
22578            .any(|record| matches!(record.op, Op::Put { .. })));
22579        let Some(Op::TxnCommit { added_runs, epoch }) = translated.last().map(|r| &r.op) else {
22580            panic!("a commit sequence ends in TxnCommit");
22581        };
22582        assert!(added_runs.is_empty(), "no added_runs may reach a replica");
22583        assert_eq!(*epoch, commit_epoch);
22584
22585        // The translated payload applies on a replica with identical rows.
22586        let replica_dir = tempfile::tempdir().unwrap();
22587        let replica = Database::create_cluster_replica(
22588            replica_dir.path(),
22589            ClusterId::from_bytes([1; 16]),
22590            NodeId::from_bytes([2; 16]),
22591            DatabaseId::from_bytes([3; 16]),
22592        )
22593        .unwrap();
22594        replica
22595            .apply_replicated_catalog_command(&create_table_record("t", 1))
22596            .unwrap();
22597        assert_eq!(replica.table_id("t").unwrap(), table_id);
22598        assert!(replica.apply_replicated_records(&translated).unwrap());
22599        assert_eq!(visible_ids(&replica, "t"), values);
22600        assert_eq!(visible_ids(&replica, "t"), visible_ids(&leader, "t"));
22601
22602        // Standalone behavior is unchanged: the leader still recovers its
22603        // spilled commit by linking the run file.
22604        drop(leader);
22605        let leader = Database::open(leader_dir.path()).unwrap();
22606        assert_eq!(visible_ids(&leader, "t"), values);
22607    }
22608
22609    #[test]
22610    fn staged_txn_writes_validate_and_apply() {
22611        let dir = tempfile::tempdir().unwrap();
22612        let db = Database::create_cluster_replica(
22613            dir.path(),
22614            ClusterId::from_bytes([1; 16]),
22615            NodeId::from_bytes([2; 16]),
22616            DatabaseId::from_bytes([3; 16]),
22617        )
22618        .unwrap();
22619        db.apply_replicated_catalog_command(&create_table_record("t", 1))
22620            .unwrap();
22621        let table_id = db.table_id("t").unwrap();
22622
22623        // Malformed payloads and unmounted tables are rejected at prepare.
22624        assert!(db.validate_staged_txn_writes(&[vec![0xFF]]).is_err());
22625        let unknown_table = StagedTxnWrite::Put {
22626            table_id: 99,
22627            rows: bincode::serialize(&Vec::<Row>::new()).unwrap(),
22628        }
22629        .encode()
22630        .unwrap();
22631        assert!(db.validate_staged_txn_writes(&[unknown_table]).is_err());
22632        let good: Vec<Vec<u8>> = [10_i64, 20, 30]
22633            .iter()
22634            .map(|value| {
22635                let rows = vec![Row::new(crate::RowId(*value as u64), Epoch(0))
22636                    .with_column(1, Value::Int64(*value))];
22637                StagedTxnWrite::Put {
22638                    table_id,
22639                    rows: bincode::serialize(&rows).unwrap(),
22640                }
22641                .encode()
22642                .unwrap()
22643            })
22644            .collect();
22645        db.validate_staged_txn_writes(&good).unwrap();
22646
22647        // A committed resolution applies the staged writes; a delete
22648        // resolution removes them; both are replay-safe.
22649        let commit_ts = mongreldb_types::hlc::HlcTimestamp {
22650            physical_micros: 5_000,
22651            logical: 0,
22652            node_tiebreaker: 0,
22653        };
22654        assert!(db
22655            .apply_staged_txn_writes(1 << 63, &good, commit_ts)
22656            .unwrap());
22657        assert_eq!(visible_ids(&db, "t"), vec![10, 20, 30]);
22658        let delete = StagedTxnWrite::Delete {
22659            table_id,
22660            row_ids: vec![20],
22661        }
22662        .encode()
22663        .unwrap();
22664        assert!(db
22665            .apply_staged_txn_writes((1 << 63) + 1, &[delete], commit_ts)
22666            .unwrap());
22667        assert_eq!(visible_ids(&db, "t"), vec![10, 30]);
22668
22669        // Restart: the synthetic WAL transactions replay through the same
22670        // recovery path; the rows are durable.
22671        let db = Arc::new(db);
22672        db.shutdown().unwrap();
22673        let expected = crate::storage_mode::StorageMode::ClusterReplica {
22674            cluster_id: ClusterId::from_bytes([1; 16]),
22675            node_id: NodeId::from_bytes([2; 16]),
22676            database_id: DatabaseId::from_bytes([3; 16]),
22677        };
22678        let db = Database::open_cluster_replica(dir.path(), &expected).unwrap();
22679        assert_eq!(visible_ids(&db, "t"), vec![10, 30]);
22680    }
22681}