Skip to main content

mongreldb_core/
catalog_cmds.rs

1//! Versioned catalog commands (spec §10.6, S1F-001).
2//!
3//! Every logical catalog mutation is expressed as a versioned
4//! [`CatalogCommand`] wrapped in a [`CatalogCommandRecord`]. The record carries
5//! an explicit encoding `version` (spec §4.10 — decode fails closed on an
6//! unknown version, unknown variant, or unknown field) and a monotonic
7//! `catalog_version` assigned by the catalog the command is applied to.
8//!
9//! # Durability model
10//!
11//! The CATALOG file is demoted from sole authority to a checkpoint. Command
12//! records ride inside the existing persistence mechanics with **no on-disk
13//! format change**:
14//!
15//! - `Catalog` retains a bounded in-struct history (`command_log`), so the
16//!   existing `DdlOp::CatalogSnapshot` WAL payload (`DdlOp::encode_catalog`)
17//!   and the `<root>/CATALOG` checkpoint both carry the applied command
18//!   records automatically.
19//! - [`Catalog::apply_command`] validates, applies, bumps `catalog_version`,
20//!   and appends the record to the bounded history;
21//!   [`Catalog::apply_command_and_checkpoint`] then rewrites the checkpoint
22//!   through the existing atomic write path.
23//!
24//! Application is deterministic: [`apply`] is a pure function from
25//! `(&Catalog, &CatalogCommand)` to a resolved [`CatalogDelta`], and
26//! `CatalogDelta::apply_to` replays the resolved change verbatim. Replaying
27//! the same record against the same catalog version is an idempotent no-op
28//! (see [`Catalog::apply_command`]).
29//!
30//! # Authorization boundary (spec §4.6) — landed design
31//!
32//! [`required_permission`] is the **permission map** for every command:
33//!
34//! - table/column/index, trigger/procedure, and materialized-view commands
35//!   require [`Permission::Ddl`];
36//! - user/role/grant/revoke, RLS-policy/column-mask, resource-group, and
37//!   job-definition commands require [`Permission::Admin`].
38//!
39//! **Enforcement sits on the emitter, not in pure apply.** `Database`
40//! mutation entry points call `require` / `require_for` against the caller's
41//! principal (using the same map) before proposing or applying catalog work.
42//! [`apply`] / [`Catalog::apply_command`] remain deterministic and
43//! principal-free so a replica can replay an already-authorized record
44//! without re-evaluating session identity. Emitters that propose
45//! `CatalogCommand`s MUST check `required_permission` against the current
46//! principal so revocation stays effective without reopening the core.
47//!
48//! # Canonical type aliases
49//!
50//! - [`ResourceGroupDef`] is a compatibility alias for
51//!   [`crate::resource::ResourceGroup`]. `SetResourceGroup` keeps name-only
52//!   validation; full group invariants live in
53//!   [`crate::resource::ResourceGroup::validate`].
54//! - [`JobDefinition`] is the catalog-level job-definition record; `kind` /
55//!   `state` re-export the S1F-002 types in [`crate::jobs`]. `SetJobState`
56//!   only guards terminal states; the full transition machine belongs to the
57//!   `JOBS` registry.
58//!
59//! Released CATALOG files carry none of these keys (serde-defaulted fields),
60//! so they open unchanged.
61//!
62//! # Surfaces not expressed as catalog commands
63//!
64//! Databases (the catalog is single-database; `db_epoch`/id counters remain
65//! engine-managed), external tables, hidden CTAS building-table states,
66//! SQLite pragmas (`user_version`/`application_id`), and `require_auth`
67//! bootstrap stay on direct `Database`/`Catalog` fields. `db_epoch` is NOT
68//! bumped by command application — epochs stay the commit sequencer's
69//! authority; `catalog_version` orders commands.
70
71use crate::auth::{Permission, RoleEntry, UserEntry};
72use crate::catalog::{Catalog, CatalogEntry, MaterializedViewEntry, TableState};
73use crate::error::{MongrelError, Result};
74use crate::procedure::{ProcedureEntry, StoredProcedure};
75use crate::schema::{ColumnDef, IndexDef, Schema, TypeId};
76use crate::security::{ColumnMask, MaskStrategy, RowPolicy, SecurityCatalog, SecurityExpr};
77use crate::trigger::{StoredTrigger, TriggerEntry, TriggerTarget};
78use serde::{Deserialize, Serialize};
79
80/// Encoding format version of [`CatalogCommandRecord`] (spec §4.10). Unknown
81/// versions fail closed on decode.
82pub const CATALOG_COMMAND_FORMAT_VERSION: u16 = 1;
83
84/// Bound on the retained command-history tail in [`Catalog::command_log`].
85/// Keeps CATALOG checkpoints and `CatalogSnapshot` WAL payloads bounded while
86/// retaining ample `commands_since` history for single-node operation.
87pub const COMMAND_HISTORY_LIMIT: usize = 256;
88
89/// The catalog-level resource-group payload: the canonical S1E-002
90/// [`crate::resource::ResourceGroup`], aliased for the catalog code that named
91/// the forward-reference placeholder. Its serde shape is the durable contract;
92/// released CATALOG files carry no `resource_groups` key and open with the
93/// field serde-defaulted to empty.
94pub type ResourceGroupDef = crate::resource::ResourceGroup;
95
96pub use crate::jobs::{JobKind, JobState};
97
98/// Persistent job definition recorded in the catalog (spec §10.6, S1F-001).
99/// `kind`/`state` are the canonical S1F-002 types re-exported above; the live
100/// state machine and per-job progress live in the `JOBS` registry
101/// ([`crate::jobs::JobRegistry`]).
102#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
103#[serde(deny_unknown_fields)]
104pub struct JobDefinition {
105    /// Unique, caller-allocated job id. Never reused.
106    pub job_id: u64,
107    pub kind: JobKind,
108    pub state: JobState,
109    /// Primary target (table, index, or materialized-view name), if any.
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    pub target: Option<String>,
112    pub created_epoch: u64,
113    pub updated_epoch: u64,
114}
115
116/// One logical catalog mutation (spec §10.6, S1F-001).
117///
118/// Variants carry already-validated payloads (mirroring how `DdlOp` records
119/// carry resolved `ColumnDef`/schema JSON): deep semantic validation
120/// (expression trees, SQL typing) happens at the emitting layer, while
121/// [`apply`] enforces the catalog-level structural invariants fail-closed
122/// (existence, uniqueness, id allocation, reference integrity).
123#[derive(Debug, Clone, Serialize, Deserialize)]
124pub enum CatalogCommand {
125    // ── Tables and columns ────────────────────────────────────────────
126    /// Create a live table. `table_id` is allocated deterministically from
127    /// `next_table_id` at apply time and stamped as `schema.schema_id`.
128    CreateTable {
129        name: String,
130        schema: Schema,
131        created_epoch: u64,
132    },
133    /// Logically drop a live table. Cascades to triggers targeting the
134    /// table, the same-named materialized-view definition, RLS state,
135    /// policies, masks, and table-scoped role permissions (mirrors
136    /// `Database::drop_table`).
137    DropTable {
138        name: String,
139        at_epoch: u64,
140    },
141    /// Rename a live table. Retargets triggers and renames the same-named
142    /// materialized-view definition. `name == new_name` is a recorded no-op
143    /// (mirrors `Database::rename_table`).
144    RenameTable {
145        name: String,
146        new_name: String,
147        at_epoch: u64,
148    },
149    /// Replace one existing column definition (same `id`) with an
150    /// already-validated one. Mirrors `DdlOp::AlterTable`.
151    AlterColumn {
152        table: String,
153        column: ColumnDef,
154    },
155    /// Add a column with a caller-allocated, unused `id`.
156    AddColumn {
157        table: String,
158        column: ColumnDef,
159    },
160    /// Drop a column by name; indexes referencing it are dropped too
161    /// (mirrors the SQL `ALTER TABLE ... DROP COLUMN` rebuild path).
162    DropColumn {
163        table: String,
164        column: String,
165    },
166    // ── Indexes ───────────────────────────────────────────────────────
167    /// Add a secondary index definition to a live table.
168    AddIndex {
169        table: String,
170        index: IndexDef,
171    },
172    /// Remove one index by exact name. Compound SQL index names expand to
173    /// one command per [`IndexDef`] at the emitting layer.
174    RemoveIndex {
175        table: String,
176        name: String,
177    },
178    // ── Users, roles, grants ──────────────────────────────────────────
179    /// Create a user. `id` is allocated deterministically from
180    /// `next_user_id` (minimum 1) at apply time.
181    CreateUser {
182        username: String,
183        password_hash: String,
184        is_admin: bool,
185        created_epoch: u64,
186    },
187    DropUser {
188        username: String,
189    },
190    AlterUserPassword {
191        username: String,
192        password_hash: String,
193    },
194    SetUserAdmin {
195        username: String,
196        is_admin: bool,
197    },
198    CreateRole {
199        name: String,
200        created_epoch: u64,
201    },
202    /// Drop a role and strip it from every user (mirrors
203    /// `Database::drop_role`).
204    DropRole {
205        name: String,
206    },
207    /// Grant a role to a user. Idempotent: granting an already-held role is
208    /// a recorded no-op (mirrors `Database::grant_role`).
209    GrantRole {
210        username: String,
211        role: String,
212    },
213    /// Revoke a role from a user. Idempotent no-op when not held.
214    RevokeRole {
215        username: String,
216        role: String,
217    },
218    /// Grant a permission to a role, merging column lists for column-scoped
219    /// grants. Idempotent no-op when the merged set is unchanged.
220    GrantPermission {
221        role: String,
222        permission: Permission,
223    },
224    /// Revoke a permission from a role. Idempotent no-op when unchanged.
225    RevokePermission {
226        role: String,
227        permission: Permission,
228    },
229    // ── Row-level security and masks ──────────────────────────────────
230    EnableRls {
231        table: String,
232    },
233    DisableRls {
234        table: String,
235    },
236    /// Create or replace a row policy keyed by `(table, name)`.
237    SetRowPolicy {
238        policy: RowPolicy,
239    },
240    DropRowPolicy {
241        table: String,
242        name: String,
243    },
244    /// Create or replace a column mask keyed by `(table, name)`.
245    SetColumnMask {
246        mask: ColumnMask,
247    },
248    DropColumnMask {
249        table: String,
250        name: String,
251    },
252    /// Wholesale security-catalog replacement (RLS tables, policies, masks),
253    /// mirroring `Database::set_security_catalog`. Validated against the
254    /// candidate catalog exactly like the legacy path.
255    SetSecurityCatalog {
256        security: SecurityCatalog,
257    },
258    // ── Triggers and procedures ───────────────────────────────────────
259    CreateTrigger {
260        trigger: StoredTrigger,
261    },
262    DropTrigger {
263        name: String,
264    },
265    CreateProcedure {
266        procedure: StoredProcedure,
267    },
268    DropProcedure {
269        name: String,
270    },
271    // ── Materialized views ────────────────────────────────────────────
272    /// Create or replace a materialized-view definition. The backing live
273    /// table must already exist (mirrors `Database::set_materialized_view`).
274    CreateMaterializedView {
275        definition: MaterializedViewEntry,
276    },
277    /// Drop only the definition; the physical table is dropped separately
278    /// via [`CatalogCommand::DropTable`] (which also cascades definitions).
279    DropMaterializedView {
280        name: String,
281    },
282    /// Record refresh bookkeeping: bump `last_refresh_epoch` and, for
283    /// incremental views, advance the CDC checkpoint when provided.
284    RefreshMaterializedView {
285        name: String,
286        at_epoch: u64,
287        #[serde(default, skip_serializing_if = "Option::is_none")]
288        checkpoint_event_id: Option<String>,
289    },
290    // ── Resource groups (forward reference) ───────────────────────────
291    /// Create or replace a resource group.
292    SetResourceGroup {
293        group: ResourceGroupDef,
294    },
295    RemoveResourceGroup {
296        name: String,
297    },
298    // ── Job definitions (forward reference) ───────────────────────────
299    /// Submit a new persistent job. `job_id` must be unused.
300    SubmitJob {
301        job: JobDefinition,
302    },
303    /// Record a job state change. Transitions out of terminal states
304    /// (`Succeeded`/`Failed`) fail closed; S1F-002 owns the full machine.
305    SetJobState {
306        job_id: u64,
307        state: JobState,
308        at_epoch: u64,
309    },
310    // ── Serde-appended replacements (decode of older records unaffected) ──
311    /// Create or replace a trigger, keyed by name. `trigger` is the resolved
312    /// image: the emitter stamps `created_epoch`/`updated_epoch`/`version`
313    /// from the commit epoch (and bumps `version` on replacement) before
314    /// proposing, so replay is verbatim (mirrors
315    /// `Database::create_or_replace_trigger`).
316    ReplaceTrigger {
317        trigger: StoredTrigger,
318    },
319    /// Create or replace a stored procedure, keyed by name; the payload is
320    /// the epoch-resolved image (mirrors
321    /// `Database::create_or_replace_procedure`).
322    ReplaceProcedure {
323        procedure: StoredProcedure,
324    },
325}
326
327/// A [`CatalogCommand`] plus its versioning envelope (spec §4.10).
328#[derive(Debug, Clone, Serialize, Deserialize)]
329#[serde(deny_unknown_fields)]
330pub struct CatalogCommandRecord {
331    /// Encoding format version; must equal [`CATALOG_COMMAND_FORMAT_VERSION`].
332    pub version: u16,
333    /// Monotonic catalog version this record assigns (`previous + 1`).
334    pub catalog_version: u64,
335    /// The mutation itself.
336    pub command: CatalogCommand,
337}
338
339impl CatalogCommandRecord {
340    /// Build the next record for `catalog` carrying `command`.
341    pub fn next(catalog: &Catalog, command: CatalogCommand) -> Self {
342        Self {
343            version: CATALOG_COMMAND_FORMAT_VERSION,
344            catalog_version: catalog.catalog_version.saturating_add(1),
345            command,
346        }
347    }
348}
349
350/// The resolved, deterministic effect of [`apply`].
351///
352/// Every variant carries fully-resolved values (allocated ids, complete
353/// replacement schemas/security catalogs) so `apply_to` is a mechanical
354/// replay with no further decisions.
355#[derive(Debug, Clone)]
356pub enum CatalogDelta {
357    /// Nothing changed: idempotent re-application or an inherently no-op
358    /// command (e.g. granting an already-held role).
359    NoOp,
360    TableCreated {
361        entry: CatalogEntry,
362    },
363    TableDropped {
364        table_id: u64,
365        name: String,
366        at_epoch: u64,
367    },
368    TableRenamed {
369        table_id: u64,
370        old_name: String,
371        new_name: String,
372        at_epoch: u64,
373    },
374    /// Column and index mutations resolve to a whole-schema replacement.
375    SchemaReplaced {
376        table_id: u64,
377        schema: Schema,
378    },
379    /// Create, password change, admin flag, and grant/revoke-role all
380    /// resolve to a user upsert keyed by username.
381    UserUpserted(UserEntry),
382    UserRemoved {
383        username: String,
384    },
385    /// Create and grant/revoke-permission resolve to a role upsert by name.
386    RoleUpserted(RoleEntry),
387    RoleRemoved {
388        name: String,
389    },
390    /// RLS/policy/mask mutations resolve to a wholesale security-catalog
391    /// replacement (mirrors `Database::set_security_catalog`).
392    SecurityReplaced {
393        security: SecurityCatalog,
394    },
395    TriggerUpserted(TriggerEntry),
396    TriggerRemoved {
397        name: String,
398    },
399    ProcedureUpserted(ProcedureEntry),
400    ProcedureRemoved {
401        name: String,
402    },
403    MaterializedViewUpserted(MaterializedViewEntry),
404    MaterializedViewRemoved {
405        name: String,
406    },
407    ResourceGroupUpserted(ResourceGroupDef),
408    ResourceGroupRemoved {
409        name: String,
410    },
411    JobUpserted(JobDefinition),
412}
413
414impl CatalogDelta {
415    /// Replay the resolved change onto `catalog`. Deterministic: every
416    /// decision was made by [`apply`].
417    pub(crate) fn apply_to(&self, catalog: &mut Catalog) -> Result<()> {
418        match self {
419            CatalogDelta::NoOp => {}
420            CatalogDelta::TableCreated { entry } => {
421                catalog.next_table_id = entry.table_id.checked_add(1).ok_or_else(|| {
422                    MongrelError::InvalidArgument("table id space exhausted".into())
423                })?;
424                catalog.tables.push(entry.clone());
425            }
426            CatalogDelta::TableDropped {
427                table_id,
428                name,
429                at_epoch,
430            } => {
431                let entry = catalog
432                    .tables
433                    .iter_mut()
434                    .find(|table| table.table_id == *table_id)
435                    .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?;
436                entry.state = TableState::Dropped {
437                    at_epoch: *at_epoch,
438                };
439                catalog.triggers.retain(|trigger| {
440                    !matches!(
441                        &trigger.trigger.target,
442                        TriggerTarget::Table(target) if target == name
443                    )
444                });
445                catalog
446                    .materialized_views
447                    .retain(|definition| definition.name != *name);
448                catalog.security.rls_tables.retain(|table| table != name);
449                catalog
450                    .security
451                    .policies
452                    .retain(|policy| policy.table != *name);
453                catalog.security.masks.retain(|mask| mask.table != *name);
454                for role in &mut catalog.roles {
455                    role.permissions
456                        .retain(|permission| permission_table(permission) != Some(name.as_str()));
457                }
458                advance_security_version(catalog)?;
459            }
460            CatalogDelta::TableRenamed {
461                table_id,
462                old_name,
463                new_name,
464                at_epoch,
465            } => {
466                let entry = catalog
467                    .tables
468                    .iter_mut()
469                    .find(|table| table.table_id == *table_id)
470                    .ok_or_else(|| {
471                        MongrelError::NotFound(format!("table {old_name:?} not found"))
472                    })?;
473                entry.name = new_name.clone();
474                for trigger in &mut catalog.triggers {
475                    if matches!(
476                        &trigger.trigger.target,
477                        TriggerTarget::Table(target) if target == old_name
478                    ) {
479                        trigger.trigger = trigger.trigger.retarget_table(new_name, *at_epoch)?;
480                    }
481                }
482                if let Some(definition) = catalog
483                    .materialized_views
484                    .iter_mut()
485                    .find(|definition| definition.name == *old_name)
486                {
487                    definition.name = new_name.clone();
488                }
489                // Mirrors `Database::rename_table`: table-scoped security
490                // state and role permissions follow the rename, and the
491                // security version advances.
492                for table in &mut catalog.security.rls_tables {
493                    if table == old_name {
494                        *table = new_name.clone();
495                    }
496                }
497                for policy in &mut catalog.security.policies {
498                    if policy.table == *old_name {
499                        policy.table = new_name.clone();
500                    }
501                }
502                for mask in &mut catalog.security.masks {
503                    if mask.table == *old_name {
504                        mask.table = new_name.clone();
505                    }
506                }
507                for role in &mut catalog.roles {
508                    for permission in &mut role.permissions {
509                        rename_permission_table(permission, old_name, new_name);
510                    }
511                }
512                advance_security_version(catalog)?;
513            }
514            CatalogDelta::SchemaReplaced { table_id, schema } => {
515                let entry = catalog
516                    .tables
517                    .iter_mut()
518                    .find(|table| table.table_id == *table_id)
519                    .ok_or_else(|| {
520                        MongrelError::NotFound(format!("table id {table_id} not found"))
521                    })?;
522                entry.schema = schema.clone();
523            }
524            CatalogDelta::UserUpserted(entry) => {
525                match catalog
526                    .users
527                    .iter_mut()
528                    .find(|user| user.username == entry.username)
529                {
530                    Some(existing) => *existing = entry.clone(),
531                    None => {
532                        let next = entry.id.checked_add(1).ok_or_else(|| {
533                            MongrelError::Full("user-id namespace exhausted".into())
534                        })?;
535                        catalog.users.push(entry.clone());
536                        catalog.next_user_id = catalog.next_user_id.max(next);
537                    }
538                }
539                advance_security_version(catalog)?;
540            }
541            CatalogDelta::UserRemoved { username } => {
542                catalog.users.retain(|user| user.username != *username);
543                advance_security_version(catalog)?;
544            }
545            CatalogDelta::RoleUpserted(entry) => {
546                match catalog
547                    .roles
548                    .iter_mut()
549                    .find(|role| role.name == entry.name)
550                {
551                    Some(existing) => *existing = entry.clone(),
552                    None => catalog.roles.push(entry.clone()),
553                }
554                advance_security_version(catalog)?;
555            }
556            CatalogDelta::RoleRemoved { name } => {
557                catalog.roles.retain(|role| role.name != *name);
558                for user in &mut catalog.users {
559                    user.roles.retain(|role| role != name);
560                }
561                advance_security_version(catalog)?;
562            }
563            CatalogDelta::SecurityReplaced { security } => {
564                catalog.security = security.clone();
565                advance_security_version(catalog)?;
566            }
567            CatalogDelta::TriggerUpserted(entry) => {
568                match catalog
569                    .triggers
570                    .iter_mut()
571                    .find(|trigger| trigger.trigger.name == entry.trigger.name)
572                {
573                    Some(existing) => *existing = entry.clone(),
574                    None => catalog.triggers.push(entry.clone()),
575                }
576            }
577            CatalogDelta::TriggerRemoved { name } => {
578                catalog
579                    .triggers
580                    .retain(|trigger| trigger.trigger.name != *name);
581            }
582            CatalogDelta::ProcedureUpserted(entry) => {
583                match catalog
584                    .procedures
585                    .iter_mut()
586                    .find(|procedure| procedure.procedure.name == entry.procedure.name)
587                {
588                    Some(existing) => *existing = entry.clone(),
589                    None => catalog.procedures.push(entry.clone()),
590                }
591            }
592            CatalogDelta::ProcedureRemoved { name } => {
593                catalog
594                    .procedures
595                    .retain(|procedure| procedure.procedure.name != *name);
596            }
597            CatalogDelta::MaterializedViewUpserted(definition) => {
598                match catalog
599                    .materialized_views
600                    .iter_mut()
601                    .find(|existing| existing.name == definition.name)
602                {
603                    Some(existing) => *existing = definition.clone(),
604                    None => catalog.materialized_views.push(definition.clone()),
605                }
606            }
607            CatalogDelta::MaterializedViewRemoved { name } => {
608                catalog
609                    .materialized_views
610                    .retain(|definition| definition.name != *name);
611            }
612            CatalogDelta::ResourceGroupUpserted(group) => {
613                match catalog
614                    .resource_groups
615                    .iter_mut()
616                    .find(|existing| existing.name == group.name)
617                {
618                    Some(existing) => *existing = group.clone(),
619                    None => catalog.resource_groups.push(group.clone()),
620                }
621            }
622            CatalogDelta::ResourceGroupRemoved { name } => {
623                catalog.resource_groups.retain(|group| group.name != *name);
624            }
625            CatalogDelta::JobUpserted(job) => {
626                match catalog
627                    .job_definitions
628                    .iter_mut()
629                    .find(|existing| existing.job_id == job.job_id)
630                {
631                    Some(existing) => *existing = job.clone(),
632                    None => catalog.job_definitions.push(job.clone()),
633                }
634            }
635        }
636        Ok(())
637    }
638}
639
640/// The permission the emitting layer must hold to propose `command`
641/// (spec §4.6). Pure map: `Database` enforces via `require` / `require_for`
642/// before mutation; apply paths do not re-check principals (see module docs).
643pub fn required_permission(command: &CatalogCommand) -> Permission {
644    match command {
645        CatalogCommand::CreateTable { .. }
646        | CatalogCommand::DropTable { .. }
647        | CatalogCommand::RenameTable { .. }
648        | CatalogCommand::AlterColumn { .. }
649        | CatalogCommand::AddColumn { .. }
650        | CatalogCommand::DropColumn { .. }
651        | CatalogCommand::AddIndex { .. }
652        | CatalogCommand::RemoveIndex { .. }
653        | CatalogCommand::CreateTrigger { .. }
654        | CatalogCommand::DropTrigger { .. }
655        | CatalogCommand::CreateProcedure { .. }
656        | CatalogCommand::DropProcedure { .. }
657        | CatalogCommand::ReplaceTrigger { .. }
658        | CatalogCommand::ReplaceProcedure { .. }
659        | CatalogCommand::CreateMaterializedView { .. }
660        | CatalogCommand::DropMaterializedView { .. }
661        | CatalogCommand::RefreshMaterializedView { .. } => Permission::Ddl,
662        CatalogCommand::CreateUser { .. }
663        | CatalogCommand::DropUser { .. }
664        | CatalogCommand::AlterUserPassword { .. }
665        | CatalogCommand::SetUserAdmin { .. }
666        | CatalogCommand::CreateRole { .. }
667        | CatalogCommand::DropRole { .. }
668        | CatalogCommand::GrantRole { .. }
669        | CatalogCommand::RevokeRole { .. }
670        | CatalogCommand::GrantPermission { .. }
671        | CatalogCommand::RevokePermission { .. }
672        | CatalogCommand::EnableRls { .. }
673        | CatalogCommand::DisableRls { .. }
674        | CatalogCommand::SetRowPolicy { .. }
675        | CatalogCommand::DropRowPolicy { .. }
676        | CatalogCommand::SetColumnMask { .. }
677        | CatalogCommand::DropColumnMask { .. }
678        | CatalogCommand::SetSecurityCatalog { .. }
679        | CatalogCommand::SetResourceGroup { .. }
680        | CatalogCommand::RemoveResourceGroup { .. }
681        | CatalogCommand::SubmitJob { .. }
682        | CatalogCommand::SetJobState { .. } => Permission::Admin,
683    }
684}
685
686/// Validate `command` against `catalog` and resolve its deterministic effect.
687/// Pure: `catalog` is never mutated; idempotent commands resolve to
688/// [`CatalogDelta::NoOp`].
689pub fn apply(catalog: &Catalog, command: &CatalogCommand) -> Result<CatalogDelta> {
690    match command {
691        CatalogCommand::CreateTable {
692            name,
693            schema,
694            created_epoch,
695        } => {
696            if name.is_empty() || name.starts_with(crate::database::CTAS_BUILD_TABLE_PREFIX) {
697                return Err(MongrelError::InvalidArgument(format!(
698                    "invalid table name {name:?}"
699                )));
700            }
701            if catalog.live(name).is_some() || catalog.building_for(name).is_some() {
702                return Err(MongrelError::InvalidArgument(format!(
703                    "table {name:?} already exists or is being built"
704                )));
705            }
706            let mut schema = schema.clone();
707            // Schema validation runs before the command can be recorded so a
708            // replayed command stream never carries an unopenable schema
709            // (mirrors `create_table_with_state`).
710            schema.validate_auto_increment()?;
711            schema.validate_defaults()?;
712            schema.validate_ai()?;
713            for index in &schema.indexes {
714                index.validate_options()?;
715            }
716            for constraint in &schema.constraints.checks {
717                constraint.expr.validate()?;
718            }
719            let table_id = catalog.next_table_id;
720            schema.schema_id = table_id;
721            Ok(CatalogDelta::TableCreated {
722                entry: CatalogEntry {
723                    table_id,
724                    name: name.clone(),
725                    schema,
726                    state: TableState::Live,
727                    created_epoch: *created_epoch,
728                },
729            })
730        }
731        CatalogCommand::DropTable { name, at_epoch } => {
732            let entry = catalog
733                .live(name)
734                .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?;
735            Ok(CatalogDelta::TableDropped {
736                table_id: entry.table_id,
737                name: name.clone(),
738                at_epoch: *at_epoch,
739            })
740        }
741        CatalogCommand::RenameTable {
742            name,
743            new_name,
744            at_epoch,
745        } => {
746            if name == new_name {
747                return Ok(CatalogDelta::NoOp);
748            }
749            if new_name.is_empty()
750                || name.starts_with(crate::database::CTAS_BUILD_TABLE_PREFIX)
751                || new_name.starts_with(crate::database::CTAS_BUILD_TABLE_PREFIX)
752            {
753                return Err(MongrelError::InvalidArgument(
754                    "invalid table rename identity".into(),
755                ));
756            }
757            let entry = catalog
758                .live(name)
759                .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?;
760            if catalog.live(new_name).is_some() || catalog.building_for(new_name).is_some() {
761                return Err(MongrelError::InvalidArgument(format!(
762                    "a table named {new_name:?} already exists"
763                )));
764            }
765            Ok(CatalogDelta::TableRenamed {
766                table_id: entry.table_id,
767                old_name: name.clone(),
768                new_name: new_name.clone(),
769                at_epoch: *at_epoch,
770            })
771        }
772        CatalogCommand::AlterColumn { table, column } => {
773            let entry = live_entry(catalog, table)?;
774            let mut schema = entry.schema.clone();
775            let position = schema
776                .columns
777                .iter()
778                .position(|existing| existing.id == column.id)
779                .ok_or_else(|| {
780                    MongrelError::ColumnNotFound(format!("column id {} on {table}", column.id))
781                })?;
782            schema.columns[position] = column.clone();
783            // The engine (`Table::prepare_alter_column`) bumps `schema_id` on
784            // every applied alteration; the resolved delta mirrors it so a
785            // replayed command stream reproduces the same schema image.
786            schema.schema_id = schema
787                .schema_id
788                .checked_add(1)
789                .ok_or_else(|| MongrelError::Schema("schema id space exhausted".into()))?;
790            Ok(CatalogDelta::SchemaReplaced {
791                table_id: entry.table_id,
792                schema,
793            })
794        }
795        CatalogCommand::AddColumn { table, column } => {
796            let entry = live_entry(catalog, table)?;
797            if entry.schema.column(&column.name).is_some() {
798                return Err(MongrelError::InvalidArgument(format!(
799                    "column {} already exists on {table}",
800                    column.name
801                )));
802            }
803            if entry
804                .schema
805                .columns
806                .iter()
807                .any(|existing| existing.id == column.id)
808            {
809                return Err(MongrelError::InvalidArgument(format!(
810                    "column id {} already used on {table}",
811                    column.id
812                )));
813            }
814            let mut schema = entry.schema.clone();
815            schema.columns.push(column.clone());
816            schema.validate_auto_increment()?;
817            schema.validate_defaults()?;
818            schema.validate_ai()?;
819            Ok(CatalogDelta::SchemaReplaced {
820                table_id: entry.table_id,
821                schema,
822            })
823        }
824        CatalogCommand::DropColumn { table, column } => {
825            let entry = live_entry(catalog, table)?;
826            let schema = &entry.schema;
827            let target = schema.column(column).ok_or_else(|| {
828                MongrelError::ColumnNotFound(format!("column {column} on {table}"))
829            })?;
830            let dropped_id = target.id;
831            let mut schema = schema.clone();
832            schema.columns.retain(|existing| existing.id != dropped_id);
833            schema.indexes.retain(|index| index.column_id != dropped_id);
834            Ok(CatalogDelta::SchemaReplaced {
835                table_id: entry.table_id,
836                schema,
837            })
838        }
839        CatalogCommand::AddIndex { table, index } => {
840            let entry = live_entry(catalog, table)?;
841            let schema = &entry.schema;
842            if schema
843                .indexes
844                .iter()
845                .any(|existing| existing.name == index.name)
846            {
847                return Err(MongrelError::InvalidArgument(format!(
848                    "index {} already exists on {table}",
849                    index.name
850                )));
851            }
852            index.validate_options()?;
853            let mut schema = schema.clone();
854            schema.indexes.push(index.clone());
855            // validate_ai enforces column existence + kind/type compatibility.
856            schema.validate_ai()?;
857            Ok(CatalogDelta::SchemaReplaced {
858                table_id: entry.table_id,
859                schema,
860            })
861        }
862        CatalogCommand::RemoveIndex { table, name } => {
863            let entry = live_entry(catalog, table)?;
864            let schema = &entry.schema;
865            if !schema.indexes.iter().any(|index| index.name == *name) {
866                return Err(MongrelError::NotFound(format!(
867                    "index {name} does not exist on {table}"
868                )));
869            }
870            let mut schema = schema.clone();
871            schema.indexes.retain(|index| index.name != *name);
872            Ok(CatalogDelta::SchemaReplaced {
873                table_id: entry.table_id,
874                schema,
875            })
876        }
877        CatalogCommand::CreateUser {
878            username,
879            password_hash,
880            is_admin,
881            created_epoch,
882        } => {
883            if username.is_empty() {
884                return Err(MongrelError::InvalidArgument(
885                    "username must not be empty".into(),
886                ));
887            }
888            if catalog.users.iter().any(|user| user.username == *username) {
889                return Err(MongrelError::InvalidArgument(format!(
890                    "user {username:?} already exists"
891                )));
892            }
893            let id = catalog.next_user_id.max(1);
894            Ok(CatalogDelta::UserUpserted(UserEntry {
895                id,
896                username: username.clone(),
897                password_hash: password_hash.clone(),
898                roles: Vec::new(),
899                is_admin: *is_admin,
900                created_epoch: *created_epoch,
901            }))
902        }
903        CatalogCommand::DropUser { username } => {
904            if !catalog.users.iter().any(|user| user.username == *username) {
905                return Err(MongrelError::NotFound(format!(
906                    "user {username:?} not found"
907                )));
908            }
909            Ok(CatalogDelta::UserRemoved {
910                username: username.clone(),
911            })
912        }
913        CatalogCommand::AlterUserPassword {
914            username,
915            password_hash,
916        } => {
917            let user = find_user(catalog, username)?;
918            let mut user = user.clone();
919            user.password_hash = password_hash.clone();
920            Ok(CatalogDelta::UserUpserted(user))
921        }
922        CatalogCommand::SetUserAdmin { username, is_admin } => {
923            let user = find_user(catalog, username)?;
924            if user.is_admin == *is_admin {
925                return Ok(CatalogDelta::NoOp);
926            }
927            let mut user = user.clone();
928            user.is_admin = *is_admin;
929            Ok(CatalogDelta::UserUpserted(user))
930        }
931        CatalogCommand::CreateRole {
932            name,
933            created_epoch,
934        } => {
935            if name.is_empty() {
936                return Err(MongrelError::InvalidArgument(
937                    "role name must not be empty".into(),
938                ));
939            }
940            if catalog.roles.iter().any(|role| role.name == *name) {
941                return Err(MongrelError::InvalidArgument(format!(
942                    "role {name:?} already exists"
943                )));
944            }
945            Ok(CatalogDelta::RoleUpserted(RoleEntry {
946                name: name.clone(),
947                permissions: Vec::new(),
948                created_epoch: *created_epoch,
949            }))
950        }
951        CatalogCommand::DropRole { name } => {
952            if !catalog.roles.iter().any(|role| role.name == *name) {
953                return Err(MongrelError::NotFound(format!("role {name:?} not found")));
954            }
955            Ok(CatalogDelta::RoleRemoved { name: name.clone() })
956        }
957        CatalogCommand::GrantRole { username, role } => {
958            if !catalog.roles.iter().any(|entry| entry.name == *role) {
959                return Err(MongrelError::NotFound(format!("role {role:?} not found")));
960            }
961            let user = find_user(catalog, username)?;
962            if user.roles.iter().any(|held| held == role) {
963                return Ok(CatalogDelta::NoOp);
964            }
965            let mut user = user.clone();
966            user.roles.push(role.clone());
967            Ok(CatalogDelta::UserUpserted(user))
968        }
969        CatalogCommand::RevokeRole { username, role } => {
970            let user = find_user(catalog, username)?;
971            if !user.roles.iter().any(|held| held == role) {
972                return Ok(CatalogDelta::NoOp);
973            }
974            let mut user = user.clone();
975            user.roles.retain(|held| held != role);
976            Ok(CatalogDelta::UserUpserted(user))
977        }
978        CatalogCommand::GrantPermission { role, permission } => {
979            let role = find_role(catalog, role)?;
980            let mut resolved = role.clone();
981            merge_permission(&mut resolved.permissions, permission.clone());
982            if resolved.permissions == role.permissions {
983                return Ok(CatalogDelta::NoOp);
984            }
985            Ok(CatalogDelta::RoleUpserted(resolved))
986        }
987        CatalogCommand::RevokePermission { role, permission } => {
988            let role = find_role(catalog, role)?;
989            let mut resolved = role.clone();
990            revoke_permission_from(&mut resolved.permissions, permission);
991            if resolved.permissions == role.permissions {
992                return Ok(CatalogDelta::NoOp);
993            }
994            Ok(CatalogDelta::RoleUpserted(resolved))
995        }
996        CatalogCommand::EnableRls { table } => {
997            live_entry(catalog, table)?;
998            if catalog.security.rls_enabled(table) {
999                return Ok(CatalogDelta::NoOp);
1000            }
1001            let mut security = catalog.security.clone();
1002            security.rls_tables.push(table.clone());
1003            Ok(CatalogDelta::SecurityReplaced { security })
1004        }
1005        CatalogCommand::DisableRls { table } => {
1006            if !catalog.security.rls_enabled(table) {
1007                return Ok(CatalogDelta::NoOp);
1008            }
1009            let mut security = catalog.security.clone();
1010            security.rls_tables.retain(|name| name != table);
1011            Ok(CatalogDelta::SecurityReplaced { security })
1012        }
1013        CatalogCommand::SetRowPolicy { policy } => {
1014            let entry = live_entry(catalog, &policy.table)?;
1015            if let Some(expression) = &policy.using {
1016                validate_policy_columns(expression, &entry.schema)?;
1017            }
1018            if let Some(expression) = &policy.with_check {
1019                validate_policy_columns(expression, &entry.schema)?;
1020            }
1021            let mut security = catalog.security.clone();
1022            match security
1023                .policies
1024                .iter_mut()
1025                .find(|existing| existing.table == policy.table && existing.name == policy.name)
1026            {
1027                Some(existing) => *existing = policy.clone(),
1028                None => security.policies.push(policy.clone()),
1029            }
1030            Ok(CatalogDelta::SecurityReplaced { security })
1031        }
1032        CatalogCommand::DropRowPolicy { table, name } => {
1033            if !catalog
1034                .security
1035                .policies
1036                .iter()
1037                .any(|policy| policy.table == *table && policy.name == *name)
1038            {
1039                return Err(MongrelError::NotFound(format!(
1040                    "policy {name:?} on {table:?} not found"
1041                )));
1042            }
1043            let mut security = catalog.security.clone();
1044            security
1045                .policies
1046                .retain(|policy| !(policy.table == *table && policy.name == *name));
1047            Ok(CatalogDelta::SecurityReplaced { security })
1048        }
1049        CatalogCommand::SetColumnMask { mask } => {
1050            let entry = live_entry(catalog, &mask.table)?;
1051            let column = entry
1052                .schema
1053                .columns
1054                .iter()
1055                .find(|column| column.id == mask.column)
1056                .ok_or_else(|| {
1057                    MongrelError::NotFound(format!(
1058                        "mask column {} on {:?} not found",
1059                        mask.column, mask.table
1060                    ))
1061                })?;
1062            if matches!(
1063                mask.strategy,
1064                MaskStrategy::Redact { .. } | MaskStrategy::Sha256
1065            ) && !matches!(column.ty, TypeId::Bytes | TypeId::Enum { .. })
1066            {
1067                return Err(MongrelError::InvalidArgument(format!(
1068                    "mask {:?} requires a string/bytes column",
1069                    mask.name
1070                )));
1071            }
1072            let mut security = catalog.security.clone();
1073            match security
1074                .masks
1075                .iter_mut()
1076                .find(|existing| existing.table == mask.table && existing.name == mask.name)
1077            {
1078                Some(existing) => *existing = mask.clone(),
1079                None => security.masks.push(mask.clone()),
1080            }
1081            Ok(CatalogDelta::SecurityReplaced { security })
1082        }
1083        CatalogCommand::DropColumnMask { table, name } => {
1084            if !catalog
1085                .security
1086                .masks
1087                .iter()
1088                .any(|mask| mask.table == *table && mask.name == *name)
1089            {
1090                return Err(MongrelError::NotFound(format!(
1091                    "mask {name:?} on {table:?} not found"
1092                )));
1093            }
1094            let mut security = catalog.security.clone();
1095            security
1096                .masks
1097                .retain(|mask| !(mask.table == *table && mask.name == *name));
1098            Ok(CatalogDelta::SecurityReplaced { security })
1099        }
1100        CatalogCommand::SetSecurityCatalog { security } => {
1101            // Wholesale replacement: validated against the candidate catalog
1102            // with the same validator the legacy `set_security_catalog` path
1103            // runs, so a replayed command stream never carries an unopenable
1104            // security catalog.
1105            crate::database::validate_security_catalog(catalog, security)?;
1106            Ok(CatalogDelta::SecurityReplaced {
1107                security: security.clone(),
1108            })
1109        }
1110        CatalogCommand::CreateTrigger { trigger } => {
1111            trigger.validate()?;
1112            if let TriggerTarget::Table(target) = &trigger.target {
1113                if catalog.live(target).is_none() {
1114                    return Err(MongrelError::InvalidArgument(format!(
1115                        "trigger {:?} references unknown target table {target:?}",
1116                        trigger.name
1117                    )));
1118                }
1119            }
1120            if catalog
1121                .triggers
1122                .iter()
1123                .any(|entry| entry.trigger.name == trigger.name)
1124            {
1125                return Err(MongrelError::TriggerValidation(format!(
1126                    "trigger {:?} already exists",
1127                    trigger.name
1128                )));
1129            }
1130            Ok(CatalogDelta::TriggerUpserted(TriggerEntry::from(
1131                trigger.clone(),
1132            )))
1133        }
1134        CatalogCommand::DropTrigger { name } => {
1135            if !catalog
1136                .triggers
1137                .iter()
1138                .any(|entry| entry.trigger.name == *name)
1139            {
1140                return Err(MongrelError::NotFound(format!(
1141                    "trigger {name:?} not found"
1142                )));
1143            }
1144            Ok(CatalogDelta::TriggerRemoved { name: name.clone() })
1145        }
1146        CatalogCommand::CreateProcedure { procedure } => {
1147            procedure.validate()?;
1148            for step in &procedure.body.steps {
1149                if let Some(table) = step.table() {
1150                    if catalog.live(table).is_none() {
1151                        return Err(MongrelError::InvalidArgument(format!(
1152                            "procedure {:?} references unknown table {table:?}",
1153                            procedure.name
1154                        )));
1155                    }
1156                }
1157            }
1158            if catalog
1159                .procedures
1160                .iter()
1161                .any(|entry| entry.procedure.name == procedure.name)
1162            {
1163                return Err(MongrelError::InvalidArgument(format!(
1164                    "procedure {:?} already exists",
1165                    procedure.name
1166                )));
1167            }
1168            Ok(CatalogDelta::ProcedureUpserted(ProcedureEntry::from(
1169                procedure.clone(),
1170            )))
1171        }
1172        CatalogCommand::DropProcedure { name } => {
1173            if !catalog
1174                .procedures
1175                .iter()
1176                .any(|entry| entry.procedure.name == *name)
1177            {
1178                return Err(MongrelError::NotFound(format!(
1179                    "procedure {name:?} not found"
1180                )));
1181            }
1182            Ok(CatalogDelta::ProcedureRemoved { name: name.clone() })
1183        }
1184        CatalogCommand::ReplaceTrigger { trigger } => {
1185            // CreateTrigger's structural validation without the must-not-exist
1186            // rule: an existing entry is replaced by name. The payload is the
1187            // emitter-resolved image (epochs/version already stamped), so the
1188            // delta replays it verbatim.
1189            trigger.validate()?;
1190            if let TriggerTarget::Table(target) = &trigger.target {
1191                if catalog.live(target).is_none() {
1192                    return Err(MongrelError::InvalidArgument(format!(
1193                        "trigger {:?} references unknown target table {target:?}",
1194                        trigger.name
1195                    )));
1196                }
1197            }
1198            Ok(CatalogDelta::TriggerUpserted(TriggerEntry::from(
1199                trigger.clone(),
1200            )))
1201        }
1202        CatalogCommand::ReplaceProcedure { procedure } => {
1203            procedure.validate()?;
1204            for step in &procedure.body.steps {
1205                if let Some(table) = step.table() {
1206                    if catalog.live(table).is_none() {
1207                        return Err(MongrelError::InvalidArgument(format!(
1208                            "procedure {:?} references unknown table {table:?}",
1209                            procedure.name
1210                        )));
1211                    }
1212                }
1213            }
1214            Ok(CatalogDelta::ProcedureUpserted(ProcedureEntry::from(
1215                procedure.clone(),
1216            )))
1217        }
1218        CatalogCommand::CreateMaterializedView { definition } => {
1219            if definition.name.is_empty() || definition.query.trim().is_empty() {
1220                return Err(MongrelError::InvalidArgument(
1221                    "materialized view name and query must not be empty".into(),
1222                ));
1223            }
1224            if catalog.live(&definition.name).is_none() {
1225                return Err(MongrelError::NotFound(format!(
1226                    "materialized view table {:?} not found",
1227                    definition.name
1228                )));
1229            }
1230            Ok(CatalogDelta::MaterializedViewUpserted(definition.clone()))
1231        }
1232        CatalogCommand::DropMaterializedView { name } => {
1233            if !catalog
1234                .materialized_views
1235                .iter()
1236                .any(|definition| definition.name == *name)
1237            {
1238                return Err(MongrelError::NotFound(format!(
1239                    "materialized view {name:?} not found"
1240                )));
1241            }
1242            Ok(CatalogDelta::MaterializedViewRemoved { name: name.clone() })
1243        }
1244        CatalogCommand::RefreshMaterializedView {
1245            name,
1246            at_epoch,
1247            checkpoint_event_id,
1248        } => {
1249            let definition = catalog
1250                .materialized_views
1251                .iter()
1252                .find(|definition| definition.name == *name)
1253                .ok_or_else(|| {
1254                    MongrelError::NotFound(format!("materialized view {name:?} not found"))
1255                })?;
1256            let mut definition = definition.clone();
1257            definition.last_refresh_epoch = *at_epoch;
1258            if let Some(checkpoint) = checkpoint_event_id {
1259                let plan = definition.incremental.as_mut().ok_or_else(|| {
1260                    MongrelError::InvalidArgument(format!(
1261                        "materialized view {name:?} has no incremental plan"
1262                    ))
1263                })?;
1264                plan.checkpoint_event_id = checkpoint.clone();
1265            }
1266            Ok(CatalogDelta::MaterializedViewUpserted(definition))
1267        }
1268        CatalogCommand::SetResourceGroup { group } => {
1269            if group.name.is_empty() {
1270                return Err(MongrelError::InvalidArgument(
1271                    "resource group name must not be empty".into(),
1272                ));
1273            }
1274            Ok(CatalogDelta::ResourceGroupUpserted(group.clone()))
1275        }
1276        CatalogCommand::RemoveResourceGroup { name } => {
1277            if !catalog
1278                .resource_groups
1279                .iter()
1280                .any(|group| group.name == *name)
1281            {
1282                return Err(MongrelError::NotFound(format!(
1283                    "resource group {name:?} not found"
1284                )));
1285            }
1286            Ok(CatalogDelta::ResourceGroupRemoved { name: name.clone() })
1287        }
1288        CatalogCommand::SubmitJob { job } => {
1289            if catalog
1290                .job_definitions
1291                .iter()
1292                .any(|existing| existing.job_id == job.job_id)
1293            {
1294                return Err(MongrelError::InvalidArgument(format!(
1295                    "job id {} already exists",
1296                    job.job_id
1297                )));
1298            }
1299            Ok(CatalogDelta::JobUpserted(job.clone()))
1300        }
1301        CatalogCommand::SetJobState {
1302            job_id,
1303            state,
1304            at_epoch,
1305        } => {
1306            let job = catalog
1307                .job_definitions
1308                .iter()
1309                .find(|job| job.job_id == *job_id)
1310                .ok_or_else(|| MongrelError::NotFound(format!("job id {job_id} not found")))?;
1311            if job.state.is_terminal() {
1312                return Err(MongrelError::InvalidArgument(format!(
1313                    "job id {job_id} is in terminal state {:?}",
1314                    job.state
1315                )));
1316            }
1317            let mut job = job.clone();
1318            job.state = *state;
1319            job.updated_epoch = *at_epoch;
1320            Ok(CatalogDelta::JobUpserted(job))
1321        }
1322    }
1323}
1324
1325/// Encode a command record (JSON; deterministic field order).
1326pub fn encode_command(record: &CatalogCommandRecord) -> Result<Vec<u8>> {
1327    serde_json::to_vec(record)
1328        .map_err(|error| MongrelError::Other(format!("catalog command serialize: {error}")))
1329}
1330
1331/// Decode a command record, failing closed (spec §4.10) on malformed JSON,
1332/// unknown variants/fields, or an unsupported encoding version.
1333pub fn decode_command(bytes: &[u8]) -> Result<CatalogCommandRecord> {
1334    let record: CatalogCommandRecord = serde_json::from_slice(bytes)
1335        .map_err(|error| MongrelError::Other(format!("catalog command deserialize: {error}")))?;
1336    if record.version != CATALOG_COMMAND_FORMAT_VERSION {
1337        return Err(MongrelError::UnsupportedStorageVersion {
1338            component: "catalog command",
1339            found: record.version,
1340            supported: CATALOG_COMMAND_FORMAT_VERSION,
1341        });
1342    }
1343    Ok(record)
1344}
1345
1346fn live_entry<'a>(catalog: &'a Catalog, table: &str) -> Result<&'a CatalogEntry> {
1347    catalog
1348        .live(table)
1349        .ok_or_else(|| MongrelError::NotFound(format!("table {table:?} not found")))
1350}
1351
1352fn find_user<'a>(catalog: &'a Catalog, username: &str) -> Result<&'a UserEntry> {
1353    catalog
1354        .users
1355        .iter()
1356        .find(|user| user.username == username)
1357        .ok_or_else(|| MongrelError::NotFound(format!("user {username:?} not found")))
1358}
1359
1360fn find_role<'a>(catalog: &'a Catalog, name: &str) -> Result<&'a RoleEntry> {
1361    catalog
1362        .roles
1363        .iter()
1364        .find(|role| role.name == name)
1365        .ok_or_else(|| MongrelError::NotFound(format!("role {name:?} not found")))
1366}
1367
1368/// Bump the optimistic authorization snapshot counter (mirrors
1369/// `advance_security_version` in `database.rs`; unified when command routing
1370/// lands).
1371fn advance_security_version(catalog: &mut Catalog) -> Result<()> {
1372    catalog.security_version = catalog.security_version.checked_add(1).ok_or_else(|| {
1373        MongrelError::Conflict("security catalog version space is exhausted".into())
1374    })?;
1375    Ok(())
1376}
1377
1378/// Table a permission references (mirrors `permission_table` in
1379/// `database.rs`; unified when command routing lands).
1380fn permission_table(permission: &Permission) -> Option<&str> {
1381    match permission {
1382        Permission::Select { table }
1383        | Permission::Insert { table }
1384        | Permission::Update { table }
1385        | Permission::Delete { table }
1386        | Permission::SelectColumns { table, .. }
1387        | Permission::InsertColumns { table, .. }
1388        | Permission::UpdateColumns { table, .. } => Some(table),
1389        Permission::All | Permission::Ddl | Permission::Admin => None,
1390    }
1391}
1392
1393/// Retarget a table-scoped permission on rename (mirrors
1394/// `rename_permission_table` in `database.rs`).
1395fn rename_permission_table(permission: &mut Permission, old: &str, new: &str) {
1396    let table = match permission {
1397        Permission::Select { table }
1398        | Permission::Insert { table }
1399        | Permission::Update { table }
1400        | Permission::Delete { table }
1401        | Permission::SelectColumns { table, .. }
1402        | Permission::InsertColumns { table, .. }
1403        | Permission::UpdateColumns { table, .. } => Some(table),
1404        Permission::All | Permission::Ddl | Permission::Admin => None,
1405    };
1406    if let Some(table) = table.filter(|table| table.as_str() == old) {
1407        *table = new.to_string();
1408    }
1409}
1410
1411/// Merge a granted permission, coalescing column-scoped grants on the same
1412/// table (mirrors `merge_permission` in `database.rs`).
1413fn merge_permission(permissions: &mut Vec<Permission>, permission: Permission) {
1414    let (kind, table, mut columns) = match permission {
1415        Permission::SelectColumns { table, columns } => (0, table, columns),
1416        Permission::InsertColumns { table, columns } => (1, table, columns),
1417        Permission::UpdateColumns { table, columns } => (2, table, columns),
1418        permission if !permissions.contains(&permission) => {
1419            permissions.push(permission);
1420            return;
1421        }
1422        _ => return,
1423    };
1424    for permission in permissions.iter_mut() {
1425        let existing = match permission {
1426            Permission::SelectColumns {
1427                table: existing_table,
1428                columns,
1429            } if kind == 0 && existing_table == &table => Some(columns),
1430            Permission::InsertColumns {
1431                table: existing_table,
1432                columns,
1433            } if kind == 1 && existing_table == &table => Some(columns),
1434            Permission::UpdateColumns {
1435                table: existing_table,
1436                columns,
1437            } if kind == 2 && existing_table == &table => Some(columns),
1438            _ => None,
1439        };
1440        if let Some(existing) = existing {
1441            existing.append(&mut columns);
1442            existing.sort();
1443            existing.dedup();
1444            return;
1445        }
1446    }
1447    columns.sort();
1448    columns.dedup();
1449    let permission = if kind == 0 {
1450        Permission::SelectColumns { table, columns }
1451    } else if kind == 1 {
1452        Permission::InsertColumns { table, columns }
1453    } else {
1454        Permission::UpdateColumns { table, columns }
1455    };
1456    permissions.push(permission);
1457}
1458
1459/// Remove a revoked permission, subtracting column-scoped grants (mirrors
1460/// `revoke_permission_from` in `database.rs`).
1461fn revoke_permission_from(permissions: &mut Vec<Permission>, revoked: &Permission) {
1462    let revoked_columns = match revoked {
1463        Permission::SelectColumns { table, columns } => Some((0, table, columns)),
1464        Permission::InsertColumns { table, columns } => Some((1, table, columns)),
1465        Permission::UpdateColumns { table, columns } => Some((2, table, columns)),
1466        _ => None,
1467    };
1468    let Some((kind, table, columns)) = revoked_columns else {
1469        permissions.retain(|permission| permission != revoked);
1470        return;
1471    };
1472    for permission in permissions.iter_mut() {
1473        let current = match permission {
1474            Permission::SelectColumns {
1475                table: current_table,
1476                columns,
1477            } if kind == 0 && current_table == table => Some(columns),
1478            Permission::InsertColumns {
1479                table: current_table,
1480                columns,
1481            } if kind == 1 && current_table == table => Some(columns),
1482            Permission::UpdateColumns {
1483                table: current_table,
1484                columns,
1485            } if kind == 2 && current_table == table => Some(columns),
1486            _ => None,
1487        };
1488        if let Some(current) = current {
1489            current.retain(|column| !columns.contains(column));
1490        }
1491    }
1492    permissions.retain(|permission| match permission {
1493        Permission::SelectColumns { columns, .. }
1494        | Permission::InsertColumns { columns, .. }
1495        | Permission::UpdateColumns { columns, .. } => !columns.is_empty(),
1496        _ => true,
1497    });
1498}
1499
1500/// Fail closed when a policy expression references a column id the table
1501/// schema does not have (mirrors `validate_security_expression` in
1502/// `database.rs`).
1503fn validate_policy_columns(expression: &SecurityExpr, schema: &Schema) -> Result<()> {
1504    match expression {
1505        SecurityExpr::True => Ok(()),
1506        SecurityExpr::ColumnEqCurrentUser { column }
1507        | SecurityExpr::ColumnEqValue { column, .. } => {
1508            if schema
1509                .columns
1510                .iter()
1511                .any(|candidate| candidate.id == *column)
1512            {
1513                Ok(())
1514            } else {
1515                Err(MongrelError::InvalidArgument(format!(
1516                    "security expression references unknown column id {column}"
1517                )))
1518            }
1519        }
1520        SecurityExpr::And { left, right } | SecurityExpr::Or { left, right } => {
1521            validate_policy_columns(left, schema)?;
1522            validate_policy_columns(right, schema)
1523        }
1524        SecurityExpr::Not { expression } => validate_policy_columns(expression, schema),
1525    }
1526}
1527
1528#[cfg(test)]
1529mod tests {
1530    use super::*;
1531    use crate::schema::ColumnFlags;
1532
1533    fn test_schema() -> Schema {
1534        Schema {
1535            schema_id: 0,
1536            columns: vec![
1537                ColumnDef {
1538                    id: 1,
1539                    name: "id".into(),
1540                    ty: TypeId::Int64,
1541                    flags: ColumnFlags::empty().with(ColumnFlags::PRIMARY_KEY),
1542                    default_value: None,
1543                    embedding_source: None,
1544                },
1545                ColumnDef {
1546                    id: 2,
1547                    name: "secret".into(),
1548                    ty: TypeId::Bytes,
1549                    flags: ColumnFlags::empty(),
1550                    default_value: None,
1551                    embedding_source: None,
1552                },
1553            ],
1554            indexes: vec![],
1555            colocation: vec![],
1556            constraints: Default::default(),
1557            clustered: false,
1558        }
1559    }
1560
1561    fn catalog_with_table() -> Catalog {
1562        let mut catalog = Catalog::empty();
1563        let record = CatalogCommandRecord::next(
1564            &catalog,
1565            CatalogCommand::CreateTable {
1566                name: "t".into(),
1567                schema: test_schema(),
1568                created_epoch: 1,
1569            },
1570        );
1571        catalog.apply_command(&record).unwrap();
1572        catalog
1573    }
1574
1575    #[test]
1576    fn create_table_allocates_id_and_stamps_schema() {
1577        let catalog = catalog_with_table();
1578        assert_eq!(catalog.catalog_version(), 1);
1579        let entry = catalog.live("t").unwrap();
1580        assert_eq!(entry.table_id, 0);
1581        assert_eq!(entry.schema.schema_id, 0);
1582        assert_eq!(catalog.next_table_id, 1);
1583        // The same schema id must not collide with a second table.
1584        let mut catalog = catalog;
1585        let record = CatalogCommandRecord::next(
1586            &catalog,
1587            CatalogCommand::CreateTable {
1588                name: "u".into(),
1589                schema: test_schema(),
1590                created_epoch: 2,
1591            },
1592        );
1593        catalog.apply_command(&record).unwrap();
1594        assert_eq!(catalog.live("u").unwrap().table_id, 1);
1595        assert_eq!(catalog.live("u").unwrap().schema.schema_id, 1);
1596    }
1597
1598    #[test]
1599    fn duplicate_create_table_fails_closed() {
1600        let catalog = catalog_with_table();
1601        let delta = apply(
1602            &catalog,
1603            &CatalogCommand::CreateTable {
1604                name: "t".into(),
1605                schema: test_schema(),
1606                created_epoch: 9,
1607            },
1608        );
1609        assert!(matches!(delta, Err(MongrelError::InvalidArgument(_))));
1610    }
1611
1612    #[test]
1613    fn version_gap_and_replay_guard() {
1614        let mut catalog = catalog_with_table();
1615        // Gap: version 3 when the next expected is 2.
1616        let gap = CatalogCommandRecord {
1617            version: CATALOG_COMMAND_FORMAT_VERSION,
1618            catalog_version: 3,
1619            command: CatalogCommand::DisableRls { table: "t".into() },
1620        };
1621        assert!(matches!(
1622            catalog.apply_command(&gap),
1623            Err(MongrelError::Conflict(_))
1624        ));
1625        // Replay of the exact recorded command is an idempotent no-op.
1626        let recorded = catalog.commands_since(0)[0].clone();
1627        let delta = catalog.apply_command(&recorded).unwrap();
1628        assert!(matches!(delta, CatalogDelta::NoOp));
1629        assert_eq!(catalog.catalog_version(), 1);
1630        // A different command claiming an already-applied version conflicts.
1631        let conflicting = CatalogCommandRecord {
1632            version: CATALOG_COMMAND_FORMAT_VERSION,
1633            catalog_version: 1,
1634            command: CatalogCommand::CreateTable {
1635                name: "other".into(),
1636                schema: test_schema(),
1637                created_epoch: 5,
1638            },
1639        };
1640        assert!(matches!(
1641            catalog.apply_command(&conflicting),
1642            Err(MongrelError::Conflict(_))
1643        ));
1644    }
1645
1646    #[test]
1647    fn unsupported_encoding_version_fails_closed() {
1648        let mut catalog = catalog_with_table();
1649        let record = CatalogCommandRecord {
1650            version: CATALOG_COMMAND_FORMAT_VERSION + 1,
1651            catalog_version: 2,
1652            command: CatalogCommand::DisableRls { table: "t".into() },
1653        };
1654        assert!(matches!(
1655            catalog.apply_command(&record),
1656            Err(MongrelError::UnsupportedStorageVersion { .. })
1657        ));
1658    }
1659
1660    #[test]
1661    fn encode_decode_round_trip() {
1662        let catalog = catalog_with_table();
1663        let record = &catalog.commands_since(0)[0];
1664        let bytes = encode_command(record).unwrap();
1665        let decoded = decode_command(&bytes).unwrap();
1666        assert_eq!(decoded.catalog_version, record.catalog_version);
1667        assert_eq!(decoded.version, CATALOG_COMMAND_FORMAT_VERSION);
1668        assert_eq!(encode_command(&decoded).unwrap(), bytes);
1669    }
1670
1671    #[test]
1672    fn decode_rejects_unknown_version_and_variant() {
1673        let catalog = catalog_with_table();
1674        let bytes = encode_command(&catalog.commands_since(0)[0]).unwrap();
1675        let mut json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
1676        json["version"] = serde_json::json!(99);
1677        assert!(matches!(
1678            decode_command(serde_json::to_string(&json).unwrap().as_bytes()),
1679            Err(MongrelError::UnsupportedStorageVersion { .. })
1680        ));
1681        let unknown = br#"{"version":1,"catalog_version":1,"command":{"NotACommand":{}}}"#;
1682        assert!(decode_command(unknown).is_err());
1683        let unknown_field =
1684            br#"{"version":1,"catalog_version":1,"command":{"DisableRls":{"table":"t"}},"x":1}"#;
1685        assert!(decode_command(unknown_field).is_err());
1686    }
1687
1688    #[test]
1689    fn drop_table_cascades_security_and_roles() {
1690        let mut catalog = catalog_with_table();
1691        for command in [
1692            CatalogCommand::EnableRls { table: "t".into() },
1693            CatalogCommand::SetRowPolicy {
1694                policy: RowPolicy {
1695                    name: "p".into(),
1696                    table: "t".into(),
1697                    command: crate::security::PolicyCommand::Select,
1698                    subjects: vec![],
1699                    permissive: true,
1700                    using: Some(SecurityExpr::True),
1701                    with_check: None,
1702                },
1703            },
1704            CatalogCommand::SetColumnMask {
1705                mask: ColumnMask {
1706                    name: "m".into(),
1707                    table: "t".into(),
1708                    column: 2,
1709                    strategy: MaskStrategy::Sha256,
1710                    exempt_subjects: vec![],
1711                },
1712            },
1713            CatalogCommand::CreateRole {
1714                name: "r".into(),
1715                created_epoch: 1,
1716            },
1717            CatalogCommand::GrantPermission {
1718                role: "r".into(),
1719                permission: Permission::Select { table: "t".into() },
1720            },
1721        ] {
1722            let record = CatalogCommandRecord::next(&catalog, command);
1723            catalog.apply_command(&record).unwrap();
1724        }
1725        let security_before = catalog.security.clone();
1726        assert!(security_before.rls_enabled("t"));
1727        let record = CatalogCommandRecord::next(
1728            &catalog,
1729            CatalogCommand::DropTable {
1730                name: "t".into(),
1731                at_epoch: 7,
1732            },
1733        );
1734        catalog.apply_command(&record).unwrap();
1735        assert!(catalog.live("t").is_none());
1736        assert!(catalog.security.policies.is_empty());
1737        assert!(catalog.security.masks.is_empty());
1738        assert!(!catalog.security.rls_enabled("t"));
1739        assert!(catalog.roles[0].permissions.is_empty());
1740        assert!(matches!(
1741            catalog.tables[0].state,
1742            TableState::Dropped { at_epoch: 7 }
1743        ));
1744    }
1745
1746    #[test]
1747    fn grant_and_revoke_permission_merge_columns() {
1748        let mut catalog = catalog_with_table();
1749        let commands = [
1750            CatalogCommand::CreateRole {
1751                name: "r".into(),
1752                created_epoch: 1,
1753            },
1754            CatalogCommand::GrantPermission {
1755                role: "r".into(),
1756                permission: Permission::SelectColumns {
1757                    table: "t".into(),
1758                    columns: vec!["id".into()],
1759                },
1760            },
1761            CatalogCommand::GrantPermission {
1762                role: "r".into(),
1763                permission: Permission::SelectColumns {
1764                    table: "t".into(),
1765                    columns: vec!["secret".into(), "id".into()],
1766                },
1767            },
1768        ];
1769        for command in commands {
1770            let record = CatalogCommandRecord::next(&catalog, command);
1771            catalog.apply_command(&record).unwrap();
1772        }
1773        assert_eq!(
1774            catalog.roles[0].permissions,
1775            vec![Permission::SelectColumns {
1776                table: "t".into(),
1777                columns: vec!["id".into(), "secret".into()],
1778            }]
1779        );
1780        let record = CatalogCommandRecord::next(
1781            &catalog,
1782            CatalogCommand::RevokePermission {
1783                role: "r".into(),
1784                permission: Permission::SelectColumns {
1785                    table: "t".into(),
1786                    columns: vec!["id".into()],
1787                },
1788            },
1789        );
1790        catalog.apply_command(&record).unwrap();
1791        assert_eq!(
1792            catalog.roles[0].permissions,
1793            vec![Permission::SelectColumns {
1794                table: "t".into(),
1795                columns: vec!["secret".into()],
1796            }]
1797        );
1798    }
1799
1800    #[test]
1801    fn job_state_terminal_guard() {
1802        let mut catalog = Catalog::empty();
1803        let submit = CatalogCommandRecord::next(
1804            &catalog,
1805            CatalogCommand::SubmitJob {
1806                job: JobDefinition {
1807                    job_id: 1,
1808                    kind: JobKind::IndexBuild,
1809                    state: JobState::Pending,
1810                    target: Some("t".into()),
1811                    created_epoch: 1,
1812                    updated_epoch: 1,
1813                },
1814            },
1815        );
1816        catalog.apply_command(&submit).unwrap();
1817        let run = CatalogCommandRecord::next(
1818            &catalog,
1819            CatalogCommand::SetJobState {
1820                job_id: 1,
1821                state: JobState::Succeeded,
1822                at_epoch: 2,
1823            },
1824        );
1825        catalog.apply_command(&run).unwrap();
1826        let late = CatalogCommandRecord::next(
1827            &catalog,
1828            CatalogCommand::SetJobState {
1829                job_id: 1,
1830                state: JobState::Running,
1831                at_epoch: 3,
1832            },
1833        );
1834        assert!(matches!(
1835            catalog.apply_command(&late),
1836            Err(MongrelError::InvalidArgument(_))
1837        ));
1838    }
1839
1840    #[test]
1841    fn history_is_bounded() {
1842        let mut catalog = Catalog::empty();
1843        for index in 0..(COMMAND_HISTORY_LIMIT + 10) {
1844            let record = CatalogCommandRecord::next(
1845                &catalog,
1846                CatalogCommand::SubmitJob {
1847                    job: JobDefinition {
1848                        job_id: index as u64 + 1,
1849                        kind: JobKind::SchemaValidation,
1850                        state: JobState::Pending,
1851                        target: None,
1852                        created_epoch: 0,
1853                        updated_epoch: 0,
1854                    },
1855                },
1856            );
1857            catalog.apply_command(&record).unwrap();
1858        }
1859        assert_eq!(catalog.command_log.len(), COMMAND_HISTORY_LIMIT);
1860        let retained = catalog.commands_since(0);
1861        assert_eq!(retained.len(), COMMAND_HISTORY_LIMIT);
1862        // The compacted prefix is gone; the tail is contiguous.
1863        let oldest = retained[0].catalog_version;
1864        assert_eq!(
1865            oldest,
1866            catalog.catalog_version() - COMMAND_HISTORY_LIMIT as u64 + 1
1867        );
1868        // Replaying a compacted command is treated as already applied.
1869        let compacted = CatalogCommandRecord {
1870            version: CATALOG_COMMAND_FORMAT_VERSION,
1871            catalog_version: 1,
1872            command: CatalogCommand::SubmitJob {
1873                job: JobDefinition {
1874                    job_id: 999_999,
1875                    kind: JobKind::KeyRotation,
1876                    state: JobState::Pending,
1877                    target: None,
1878                    created_epoch: 0,
1879                    updated_epoch: 0,
1880                },
1881            },
1882        };
1883        let delta = catalog.apply_command(&compacted).unwrap();
1884        assert!(matches!(delta, CatalogDelta::NoOp));
1885        assert!(catalog
1886            .job_definitions
1887            .iter()
1888            .all(|job| job.job_id != 999_999));
1889    }
1890
1891    #[test]
1892    fn required_permission_matches_legacy_gates() {
1893        assert_eq!(
1894            required_permission(&CatalogCommand::CreateTable {
1895                name: "t".into(),
1896                schema: test_schema(),
1897                created_epoch: 0,
1898            }),
1899            Permission::Ddl
1900        );
1901        assert_eq!(
1902            required_permission(&CatalogCommand::EnableRls { table: "t".into() }),
1903            Permission::Admin
1904        );
1905        assert_eq!(
1906            required_permission(&CatalogCommand::DropUser {
1907                username: "u".into()
1908            }),
1909            Permission::Admin
1910        );
1911        assert_eq!(
1912            required_permission(&CatalogCommand::DropTrigger { name: "t".into() }),
1913            Permission::Ddl
1914        );
1915    }
1916
1917    fn replacement_trigger(name: &str) -> StoredTrigger {
1918        StoredTrigger::new(
1919            name,
1920            crate::trigger::TriggerDefinition {
1921                target: TriggerTarget::Table("t".into()),
1922                timing: crate::trigger::TriggerTiming::After,
1923                event: crate::trigger::TriggerEvent::Insert,
1924                update_of: Vec::new(),
1925                target_columns: Vec::new(),
1926                when: None,
1927                program: crate::trigger::TriggerProgram { steps: Vec::new() },
1928            },
1929            0,
1930        )
1931        .unwrap()
1932    }
1933
1934    fn replacement_procedure(name: &str) -> StoredProcedure {
1935        StoredProcedure::new(
1936            name,
1937            crate::procedure::ProcedureMode::ReadOnly,
1938            Vec::new(),
1939            crate::procedure::ProcedureBody {
1940                steps: Vec::new(),
1941                return_value: crate::procedure::ProcedureValue::Literal(
1942                    crate::memtable::Value::Null,
1943                ),
1944            },
1945            0,
1946        )
1947        .unwrap()
1948    }
1949
1950    #[test]
1951    fn alter_column_delta_bumps_schema_id_like_the_engine() {
1952        let mut catalog = catalog_with_table();
1953        let mut column = catalog.live("t").unwrap().schema.columns[1].clone();
1954        column.flags = column.flags.with(crate::schema::ColumnFlags::NULLABLE);
1955        let record = CatalogCommandRecord::next(
1956            &catalog,
1957            CatalogCommand::AlterColumn {
1958                table: "t".into(),
1959                column: column.clone(),
1960            },
1961        );
1962        let delta = catalog.apply_command(&record).unwrap();
1963        // The engine bumps `schema_id` on every applied alteration; the
1964        // resolved delta reproduces that image so replay stays deterministic.
1965        let CatalogDelta::SchemaReplaced { table_id, schema } = delta else {
1966            panic!("expected SchemaReplaced");
1967        };
1968        assert_eq!(table_id, 0);
1969        assert_eq!(schema.schema_id, 1);
1970        assert_eq!(schema.columns[1], column);
1971        assert_eq!(catalog.live("t").unwrap().schema.schema_id, 1);
1972        // A column id the table does not have fails closed.
1973        let mut unknown = column;
1974        unknown.id = 99;
1975        assert!(matches!(
1976            apply(
1977                &catalog,
1978                &CatalogCommand::AlterColumn {
1979                    table: "t".into(),
1980                    column: unknown,
1981                },
1982            ),
1983            Err(MongrelError::ColumnNotFound(_))
1984        ));
1985    }
1986
1987    #[test]
1988    fn replace_trigger_upserts_and_validates_references() {
1989        let mut catalog = catalog_with_table();
1990        // Create-or-replace on an absent name inserts...
1991        let record = CatalogCommandRecord::next(
1992            &catalog,
1993            CatalogCommand::ReplaceTrigger {
1994                trigger: replacement_trigger("trg"),
1995            },
1996        );
1997        let delta = catalog.apply_command(&record).unwrap();
1998        assert!(matches!(delta, CatalogDelta::TriggerUpserted(_)));
1999        assert_eq!(catalog.triggers.len(), 1);
2000        // ...and on a present name replaces in place (no duplicate, no
2001        // must-not-exist rejection like CreateTrigger).
2002        let mut resolved = replacement_trigger("trg");
2003        resolved.version = 2;
2004        resolved.updated_epoch = 7;
2005        let record = CatalogCommandRecord::next(
2006            &catalog,
2007            CatalogCommand::ReplaceTrigger {
2008                trigger: resolved.clone(),
2009            },
2010        );
2011        catalog.apply_command(&record).unwrap();
2012        assert_eq!(catalog.triggers.len(), 1);
2013        assert_eq!(catalog.triggers[0].trigger.version, 2);
2014        assert_eq!(catalog.triggers[0].trigger.updated_epoch, 7);
2015        // A trigger targeting an unknown table fails closed.
2016        let mut dangling = replacement_trigger("trg2");
2017        dangling.target = TriggerTarget::Table("missing".into());
2018        assert!(matches!(
2019            apply(
2020                &catalog,
2021                &CatalogCommand::ReplaceTrigger { trigger: dangling }
2022            ),
2023            Err(MongrelError::InvalidArgument(_))
2024        ));
2025        assert_eq!(
2026            required_permission(&CatalogCommand::ReplaceTrigger {
2027                trigger: replacement_trigger("trg"),
2028            }),
2029            Permission::Ddl
2030        );
2031    }
2032
2033    #[test]
2034    fn replace_procedure_upserts_and_validates_references() {
2035        let mut catalog = catalog_with_table();
2036        let record = CatalogCommandRecord::next(
2037            &catalog,
2038            CatalogCommand::ReplaceProcedure {
2039                procedure: replacement_procedure("proc"),
2040            },
2041        );
2042        let delta = catalog.apply_command(&record).unwrap();
2043        assert!(matches!(delta, CatalogDelta::ProcedureUpserted(_)));
2044        assert_eq!(catalog.procedures.len(), 1);
2045        // Replacement is keyed by name and replays the resolved image
2046        // verbatim (no must-not-exist rejection like CreateProcedure).
2047        let mut resolved = replacement_procedure("proc");
2048        resolved.version = 3;
2049        resolved.updated_epoch = 9;
2050        let record = CatalogCommandRecord::next(
2051            &catalog,
2052            CatalogCommand::ReplaceProcedure {
2053                procedure: resolved.clone(),
2054            },
2055        );
2056        catalog.apply_command(&record).unwrap();
2057        assert_eq!(catalog.procedures.len(), 1);
2058        assert_eq!(catalog.procedures[0].procedure.version, 3);
2059        assert_eq!(catalog.procedures[0].procedure.updated_epoch, 9);
2060        assert_eq!(
2061            required_permission(&CatalogCommand::ReplaceProcedure {
2062                procedure: replacement_procedure("proc"),
2063            }),
2064            Permission::Ddl
2065        );
2066    }
2067
2068    #[test]
2069    fn serde_appended_variants_decode_older_records() {
2070        // Records encoded before ReplaceTrigger/ReplaceProcedure existed
2071        // decode unchanged (the new variants are appended, never renumbered).
2072        let legacy = br#"{"version":1,"catalog_version":1,"command":{"DropTrigger":{"name":"t"}}}"#;
2073        let record = decode_command(legacy).unwrap();
2074        assert!(matches!(
2075            record.command,
2076            CatalogCommand::DropTrigger { ref name } if name == "t"
2077        ));
2078        // The new variants round-trip through the versioned envelope.
2079        let record = CatalogCommandRecord {
2080            version: CATALOG_COMMAND_FORMAT_VERSION,
2081            catalog_version: 2,
2082            command: CatalogCommand::ReplaceTrigger {
2083                trigger: replacement_trigger("trg"),
2084            },
2085        };
2086        let bytes = encode_command(&record).unwrap();
2087        let decoded = decode_command(&bytes).unwrap();
2088        assert_eq!(encode_command(&decoded).unwrap(), bytes);
2089    }
2090}