Skip to main content

reddb_server/runtime/
impl_core.rs

1use super::*;
2use crate::auth::column_policy_gate::ColumnAccessRequest;
3use crate::auth::UserId;
4use crate::catalog::CollectionModel;
5use crate::replication::cdc::ChangeRecord;
6use reddb_rql::ast::TableSource;
7// Authorization surface moved to `super::authz` (issue #1622). Re-export the
8// free IAM/policy-column helpers so this module's remaining dispatch code
9// (and the `impl_core::decision_to_strings` path used by
10// `server::handlers_iam_policy`) keeps calling them unqualified.
11pub(crate) use super::authz::policy_columns::*;
12
13// Graph inline-TVF/analytics moved to `super::graph_tvf` (issue #1625) and the
14// RLS injection family to `super::rls_injection`. Re-export the free fns still
15// referenced from this module (and via `crate::runtime::impl_core::…` paths by
16// sibling files) so those call sites need no edits.
17pub(crate) use super::graph_tvf::{abstract_degree_centrality, is_graph_tvf_name};
18pub(crate) use super::rls_injection::{
19    apply_foreign_table_filters, inject_rls_filters, inject_rls_into_join, rls_is_enabled,
20    rls_policy_filter, rls_policy_filter_for_kind,
21};
22// VCS command parse/execute moved to `super::vcs_command` (issue #1626). The
23// execute methods are inherent `pub(crate)` methods resolved via `self`; the
24// free parse helpers still called from the central dispatch (and by
25// `collections_referenced` here) are re-exported so those call sites need no
26// edits.
27pub(crate) use super::vcs_command::{
28    parse_runtime_vcs_command, strip_explain_prefix, walk_collections,
29};
30
31// Ranking / metrics / SLO / analytics-source DDL moved to
32// `super::impl_ranking` (issue #1627). Execute methods are inherent
33// `pub(crate)` methods resolved via `self`; no call-site edits needed.
34
35// Config / secret / KV resolution moved to `super::impl_config_secret` and the
36// query-audit + control-event families to `super::impl_audit_control`
37// (issue #1628). Methods are inherent `pub(crate)` methods resolved via `self`;
38// the one free fn still called via a `crate::runtime::impl_core::…` path
39// (`control_event_outcome_for_error`, from `impl_backup`) is re-exported so that
40// call site needs no edit. The dispatch here also still calls the three
41// audit-planning free fns, so they are pulled back into scope unqualified.
42pub(crate) use super::impl_audit_control::{
43    control_event_outcome_for_error, query_audit_plan, query_control_event_specs,
44};
45// Config/secret free helpers still called unqualified from the dispatch here.
46pub(crate) use super::impl_config_secret::{
47    insert_config_json_path, secret_sql_value_to_string, seed_storage_deploy_config,
48    show_config_json_result,
49};
50pub(super) use super::impl_core_outer_scope::{
51    first_column_values, query_references_outer_scope, relation_scopes_for_query,
52};
53pub(crate) use super::impl_core_result_cache_scopes::collect_table_refs;
54pub(super) use super::impl_core_result_cache_scopes::query_expr_result_cache_scopes;
55
56// Bootstrap / constructor / handle accessors moved to `super::impl_lifecycle`,
57// telemetry / gates / limits / shutdown to `super::impl_telemetry_accessors`,
58// and catalog / stats / maintenance to `super::impl_catalog_accessors`
59// (issue #1629). Methods are inherent `pub(crate)`/`pub` methods resolved via
60// `self`; the two moved free fns still called unqualified from the dispatch
61// here (`view_records_to_entities`) and the runtime-index rehydration paths
62// (`table_row_index_fields`) are re-exported so those call sites need no edit.
63pub(crate) use super::impl_lifecycle::{table_row_index_fields, view_records_to_entities};
64
65#[cfg(test)]
66thread_local! {
67    static RESULT_CACHE_MISS_EXECUTIONS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
68}
69
70#[cfg(test)]
71fn reset_result_cache_miss_executions() {
72    RESULT_CACHE_MISS_EXECUTIONS.with(|count| count.set(0));
73}
74
75#[cfg(test)]
76fn result_cache_miss_executions() -> u64 {
77    RESULT_CACHE_MISS_EXECUTIONS.with(std::cell::Cell::get)
78}
79
80pub use super::execution_context::{
81    capture_current_snapshot, clear_current_auth_identity, clear_current_connection_id,
82    clear_current_snapshot, clear_current_tenant, current_auth_identity_for_audit,
83    current_connection_id, current_tenant, entity_visible_under_current_snapshot,
84    entity_visible_with_context, set_current_auth_identity, set_current_auth_principal,
85    set_current_connection_id, set_current_snapshot, set_current_tenant, snapshot_bundle,
86    with_snapshot_bundle, SnapshotBundle, SnapshotContext,
87};
88pub(crate) use super::execution_context::{
89    config_read_permitted, current_auth_identity, current_config_value, current_kv_value,
90    current_role_projected, current_scope_override, current_secret_value,
91    current_snapshot_requires_index_fallback, current_user_projected, has_scope_override_active,
92    kv_read_permitted, parse_set_local_tenant, update_current_config_value,
93    update_current_kv_value, update_current_secret_value, xids_visible_under_current_snapshot,
94    ConfigSnapshotGuard, CurrentSnapshotGuard, KvStoreGuard, ScopeOverrideGuard, SecretStoreGuard,
95    TxLocalTenantGuard,
96};
97
98/// Cheap prefix check for a leading `WITH` keyword. Used to gate the
99/// CTE-aware parse in `execute_query` without paying for a full
100/// lexer pass on every statement. Treats `WITHIN` as not-a-CTE so
101/// `WITHIN TENANT '...' SELECT ...` doesn't mis-route.
102pub(super) fn has_with_prefix(sql: &str) -> bool {
103    let trimmed = sql.trim_start();
104    let head_end = trimmed
105        .find(|c: char| c.is_whitespace() || c == '(')
106        .unwrap_or(trimmed.len());
107    trimmed[..head_end].eq_ignore_ascii_case("WITH")
108}
109
110/// Same as `peek_top_level_as_of` but also returns the table name
111/// targeted by the AS OF clause (when the FROM clause names a
112/// concrete table). `None` for the table slot means scalar SELECT
113/// or a subquery source — callers treat those as "no enforcement".
114pub(super) fn peek_top_level_as_of_with_table(
115    sql: &str,
116) -> Option<(crate::application::vcs::AsOfSpec, Option<String>)> {
117    if !sql
118        .as_bytes()
119        .windows(5)
120        .any(|w| w.eq_ignore_ascii_case(b"as of"))
121    {
122        return None;
123    }
124    let parsed = reddb_rql::parser::parse(sql).ok()?;
125    let reddb_rql::ast::QueryExpr::Table(table) = parsed.query else {
126        return None;
127    };
128    let clause = table.as_of?;
129    let table_name = if table.table.is_empty() || table.table == "any" {
130        None
131    } else {
132        Some(table.table.clone())
133    };
134    let spec = match clause {
135        reddb_rql::ast::AsOfClause::Commit(h) => crate::application::vcs::AsOfSpec::Commit(h),
136        reddb_rql::ast::AsOfClause::Branch(b) => crate::application::vcs::AsOfSpec::Branch(b),
137        reddb_rql::ast::AsOfClause::Tag(t) => crate::application::vcs::AsOfSpec::Tag(t),
138        reddb_rql::ast::AsOfClause::TimestampMs(ts) => {
139            crate::application::vcs::AsOfSpec::TimestampMs(ts)
140        }
141        reddb_rql::ast::AsOfClause::Snapshot(x) => crate::application::vcs::AsOfSpec::Snapshot(x),
142    };
143    Some((spec, table_name))
144}
145
146pub(super) fn query_has_volatile_builtin(sql: &str) -> bool {
147    // Lowercase the bytes up to the first null/newline into a small
148    // stack buffer for cheap contains() checks. Most SQL fits in the
149    // buffer; longer queries fall back to owned lowercase.
150    const VOLATILE_TOKENS: &[&str] = &[
151        "pg_advisory_lock",
152        "pg_try_advisory_lock",
153        "pg_advisory_unlock",
154        "random()",
155        // `$config.<path>` / `$secret.<path>` resolve mutable runtime config /
156        // vault state at execution time (#1370). A cached result would serve a
157        // stale value after a later `SET CONFIG` / `SET SECRET`, so treat any
158        // query referencing them as volatile (never result-cache it).
159        "$config",
160        "$secret",
161        "$red.secret",
162        "$kv",
163        // NOW() / CURRENT_TIMESTAMP / CURRENT_DATE intentionally
164        // omitted for now — they ARE volatile but today's tests rely
165        // on caching them. Revisit once a tighter volatility story
166        // lands.
167    ];
168    let lowered = sql.to_ascii_lowercase();
169    VOLATILE_TOKENS.iter().any(|t| lowered.contains(t))
170}
171
172pub(super) fn query_is_ask_statement(sql: &str) -> bool {
173    let trimmed = sql.trim_start();
174    let head_end = trimmed
175        .find(|c: char| c.is_whitespace() || c == '(' || c == ';')
176        .unwrap_or(trimmed.len());
177    trimmed[..head_end].eq_ignore_ascii_case("ASK")
178}
179
180/// Pick the `(global_mode, collection_mode)` pair for an expression,
181/// or `None` for variants that opt out of intent-locking entirely
182/// (admin statements like `SHOW CONFIG`, transaction control, tenant
183/// toggles).
184///
185/// Phase-1 contract:
186/// - Reads  — `(IX-compatible) (Global, IS) → (Collection, IS)`
187/// - Writes — `(IX-compatible) (Global, IX) → (Collection, IX)`
188/// - DDL    — `(strong)        (Global, IX) → (Collection, X)`
189pub(super) fn intent_lock_modes_for(
190    expr: &QueryExpr,
191) -> Option<(
192    crate::runtime::lock_manager::LockMode,
193    crate::runtime::lock_manager::LockMode,
194)> {
195    use crate::runtime::lock_manager::LockMode::{Exclusive, IntentExclusive, IntentShared};
196
197    match expr {
198        // Reads — IS / IS.
199        QueryExpr::Table(_)
200        | QueryExpr::Join(_)
201        | QueryExpr::Vector(_)
202        | QueryExpr::Hybrid(_)
203        | QueryExpr::Graph(_)
204        | QueryExpr::Path(_)
205        | QueryExpr::Ask(_)
206        | QueryExpr::SearchCommand(_)
207        | QueryExpr::GraphCommand(_)
208        | QueryExpr::RankOf(_)
209        | QueryExpr::ApproxRankOf(_)
210        | QueryExpr::RankRange(_)
211        | QueryExpr::QueueSelect(_) => Some((IntentShared, IntentShared)),
212
213        // Writes — IX / IX. Non-tabular mutations (vector insert,
214        // graph node insert, queue push, timeseries point insert)
215        // don't carry their own dispatch arm here; they ride through
216        // the Insert variant or a command variant covered by the
217        // read-side arm above. P1.T4 expands only the TableQuery-ish
218        // writes; non-tabular kinds inherit when their DML variants
219        // land in later phases.
220        QueryExpr::Insert(_)
221        | QueryExpr::Update(_)
222        | QueryExpr::Delete(_)
223        | QueryExpr::QueueCommand(QueueCommand::Move { .. }) => {
224            Some((IntentExclusive, IntentExclusive))
225        }
226        QueryExpr::QueueCommand(_) => Some((IntentShared, IntentShared)),
227
228        // DDL — IX / X. A DDL against collection `c` blocks all
229        // other writers + readers on `c` but leaves other collections
230        // running (because Global stays IX, not X).
231        QueryExpr::CreateTable(_)
232        | QueryExpr::CreateCollection(_)
233        | QueryExpr::CreateVector(_)
234        | QueryExpr::DropTable(_)
235        | QueryExpr::DropGraph(_)
236        | QueryExpr::DropVector(_)
237        | QueryExpr::DropDocument(_)
238        | QueryExpr::DropKv(_)
239        | QueryExpr::DropCollection(_)
240        | QueryExpr::Truncate(_)
241        | QueryExpr::AlterTable(_)
242        | QueryExpr::CreateIndex(_)
243        | QueryExpr::DropIndex(_)
244        | QueryExpr::CreateTimeSeries(_)
245        | QueryExpr::CreateMetric(_)
246        | QueryExpr::AlterMetric(_)
247        | QueryExpr::CreateSlo(_)
248        | QueryExpr::DropTimeSeries(_)
249        | QueryExpr::CreateQueue(_)
250        | QueryExpr::AlterQueue(_)
251        | QueryExpr::DropQueue(_)
252        | QueryExpr::CreateTree(_)
253        | QueryExpr::DropTree(_)
254        | QueryExpr::CreatePolicy(_)
255        | QueryExpr::DropPolicy(_)
256        | QueryExpr::CreateView(_)
257        | QueryExpr::DropView(_)
258        | QueryExpr::RefreshMaterializedView(_)
259        | QueryExpr::CreateSchema(_)
260        | QueryExpr::DropSchema(_)
261        | QueryExpr::CreateSequence(_)
262        | QueryExpr::DropSequence(_)
263        | QueryExpr::CreateServer(_)
264        | QueryExpr::DropServer(_)
265        | QueryExpr::CreateForeignTable(_)
266        | QueryExpr::DropForeignTable(_) => Some((IntentExclusive, Exclusive)),
267
268        // Admin / control — skip intent locks. `SET TENANT`,
269        // `BEGIN / COMMIT / ROLLBACK`, `SET CONFIG`, `SHOW CONFIG`,
270        // `VACUUM`, etc. don't touch collection data the same way
271        // and the existing transaction layer already serialises the
272        // pieces that matter.
273        _ => None,
274    }
275}
276
277/// Best-effort collection inventory for an expression. Used to pick
278/// `Collection(...)` resources for the intent-lock guard. Overshoots
279/// are fine (take an extra IS, benign); undershoots leak writes past
280/// DDL X locks, so err on the side of listing more names.
281pub(super) fn collections_referenced(expr: &QueryExpr) -> Vec<String> {
282    let mut out = Vec::new();
283    walk_collections(expr, &mut out);
284    out.sort();
285    out.dedup();
286    out
287}
288
289impl RedDBRuntime {
290    /// Execute a query under a typed scope override without embedding
291    /// the tenant / user / role values into the SQL string. Use this
292    /// from transport middleware (HTTP / gRPC / worker loops) where the
293    /// scope is resolved from auth claims and the SQL is a parameterised
294    /// template — avoids the string-concat injection risk of building
295    /// `WITHIN TENANT '<id>' …` manually, and is drop-in compatible with
296    /// prepared statements that didn't know about tenancy.
297    ///
298    /// Precedence matches the `WITHIN` clause: the passed `scope`
299    /// overrides `SET LOCAL TENANT`, which overrides `SET TENANT`.
300    /// The override is pushed on the thread-local scope stack for the
301    /// duration of the call and popped on return — pool-shared
302    /// connections cannot leak it across requests.
303    pub fn execute_query_with_scope(
304        &self,
305        query: &str,
306        scope: crate::runtime::within_clause::ScopeOverride,
307    ) -> RedDBResult<RuntimeQueryResult> {
308        if scope.is_empty() {
309            return self.execute_query(query);
310        }
311        self.check_scope_override_privilege(&scope)?;
312        let _scope_guard = ScopeOverrideGuard::install(scope);
313        self.execute_query(query)
314    }
315
316    /// Issue #205 — single lifecycle exit for slow-query logging.
317    ///
318    /// `execute_query_inner` does the real work; this wrapper times it
319    /// and, if elapsed exceeds the configured threshold, hands the
320    /// triple `(QueryKind, elapsed_ms, sql_redacted, scope)` to the
321    /// SlowQueryLogger. The threshold + sample_pct were captured at
322    /// SlowQueryLogger construction (runtime startup), so the per-call
323    /// cost on below-threshold paths is one relaxed atomic load.
324    pub fn execute_query(&self, query: &str) -> RedDBResult<RuntimeQueryResult> {
325        let started = std::time::Instant::now();
326        self.inner.node_load_telemetry.query_start();
327        let receipt = super::transactional_replication::CommitReceiptScope::begin(self);
328        let result = self.execute_query_inner(query);
329        self.finish_query_lifecycle(query, started, result, receipt.lsn())
330    }
331
332    /// Execute a SQL statement with already-decoded positional bind
333    /// parameters. Transports should call this instead of parsing +
334    /// binding on their side and then reaching for `execute_query_expr`:
335    /// this entry keeps parameterized statements inside the same
336    /// statement lifecycle as textual SQL (snapshot guard, config/secret
337    /// guards, coarse auth, intent locks, slow-query logging, integrity
338    /// tombstone filtering, and causal bookmarks).
339    pub fn execute_query_with_params(
340        &self,
341        query: &str,
342        params: &[Value],
343    ) -> RedDBResult<RuntimeQueryResult> {
344        if params.is_empty() {
345            return self.execute_query(query);
346        }
347        let started = std::time::Instant::now();
348        self.inner.node_load_telemetry.query_start();
349        let receipt = super::transactional_replication::CommitReceiptScope::begin(self);
350        let result = self.execute_query_with_params_inner(query, params);
351        self.finish_query_lifecycle(query, started, result, receipt.lsn())
352    }
353
354    /// Execute an already-bound prepared statement together with the SQL text
355    /// it was prepared from.
356    ///
357    /// This is the prepared-statement entry point for transports. The text is
358    /// what the statement frame is built from — snapshot (including `AS OF`),
359    /// coarse privilege class, intent locks, result-cache key, and slow-query
360    /// logging all describe the real statement — so a prepared execution gets
361    /// the same statement semantics as the identical SQL text (#2183). A
362    /// transport that holds a shape in a prepared registry must keep the
363    /// original text alongside it and pass it here.
364    pub fn execute_prepared_query(
365        &self,
366        query: &str,
367        expr: QueryExpr,
368    ) -> RedDBResult<RuntimeQueryResult> {
369        let started = std::time::Instant::now();
370        self.inner.node_load_telemetry.query_start();
371        let receipt = super::transactional_replication::CommitReceiptScope::begin(self);
372        let result = self.execute_bound_query_expr_in_frame(query, expr);
373        self.finish_query_lifecycle(query, started, result, receipt.lsn())
374    }
375
376    fn finish_query_lifecycle(
377        &self,
378        query: &str,
379        started: std::time::Instant,
380        mut result: RedDBResult<RuntimeQueryResult>,
381        commit_lsn: Option<u64>,
382    ) -> RedDBResult<RuntimeQueryResult> {
383        // Issue #765 / S6 — filter integrity-tombstoned rows out of SELECT
384        // results before they reach any consumer. Fast no-op (one relaxed
385        // atomic load) unless an input-stream digest mismatch has tombstoned
386        // a RID range on this store.
387        result = result.and_then(|mut value| {
388            if value.statement_type == "select" {
389                self.filter_integrity_tombstoned(&mut value.result)?;
390            }
391            super::query_budget::check_result(self.resource_limits(), &value)?;
392            Ok(value)
393        });
394        self.finish_collection_description_transaction(&result);
395        let elapsed_ms = started.elapsed().as_millis() as u64;
396
397        // Build EffectiveScope from the same thread-locals frame-build
398        // consults — keeps the slow-log row consistent with the audit /
399        // RLS view of "this statement". `ai_scope()` is the canonical
400        // builder.
401        let scope = self.ai_scope();
402        let kind = match result
403            .as_ref()
404            .map(|r| r.statement_type)
405            .unwrap_or("select")
406        {
407            "select" => crate::telemetry::slow_query_logger::QueryKind::Select,
408            "insert" => crate::telemetry::slow_query_logger::QueryKind::Insert,
409            "update" => crate::telemetry::slow_query_logger::QueryKind::Update,
410            "delete" => crate::telemetry::slow_query_logger::QueryKind::Delete,
411            _ => crate::telemetry::slow_query_logger::QueryKind::Internal,
412        };
413        let logged_query = redact_sql_for_slow_log(query);
414        self.inner
415            .slow_query_logger
416            .record(kind, elapsed_ms, logged_query, &scope);
417
418        // Issue #1241 — record latency into the bounded per-`kind`
419        // histogram substrate (always, not only above the slow-query
420        // threshold). `started.elapsed()` is re-read here for sub-ms
421        // resolution; the cost is one `Instant::now` plus a handful of
422        // relaxed atomic adds (see `query_latency_telemetry` docs).
423        self.inner
424            .query_latency_telemetry
425            .observe(kind, started.elapsed().as_secs_f64());
426
427        // Issue #1245 — decrement the active-query gauge. One relaxed
428        // atomic sub; the matching increment happened at execute_query /
429        // execute_query_with_params entry.
430        self.inner.node_load_telemetry.query_finish();
431
432        if let Ok(ref mut query_result) = result {
433            if matches!(
434                query_result.statement_type,
435                "insert" | "update" | "delete" | "commit"
436            ) {
437                let lsn = if self.inner.db.replication.is_some() {
438                    commit_lsn
439                } else if self.current_xid().is_none() {
440                    Some(self.cdc_current_lsn())
441                } else {
442                    None
443                };
444                query_result.bookmark = lsn.map(|lsn| {
445                    crate::replication::CausalBookmark::new(self.current_replication_term(), lsn)
446                        .encode()
447                });
448            }
449        }
450
451        result
452    }
453
454    fn execute_query_with_params_inner(
455        &self,
456        query: &str,
457        params: &[Value],
458    ) -> RedDBResult<RuntimeQueryResult> {
459        let parsed = self.parameterized_query_ast(query)?;
460        let bound = crate::storage::query::user_params::bind(&parsed, params).map_err(|err| {
461            RedDBError::Validation {
462                message: err.to_string(),
463                validation: crate::json!({
464                    "code": "INVALID_PARAMS",
465                    "surface": "query.params",
466                }),
467            }
468        })?;
469        self.execute_bound_query_expr_in_frame(query, bound)
470    }
471
472    /// Cache only the unbound parser output. Binding, views, authorization,
473    /// configuration and snapshots are resolved anew for every execution.
474    fn parameterized_query_ast(&self, query: &str) -> RedDBResult<QueryExpr> {
475        use crate::storage::query::planner::{CachedPlan, QueryPlan};
476
477        // Keep exact parameterized statements separate from normalized plans.
478        let key = format!("\0parameters\0{query}");
479        if let Some(parsed) = self
480            .inner
481            .query_cache
482            .read()
483            .peek(&key)
484            .filter(|entry| entry.matches_exact_query(query))
485            .map(|entry| entry.plan.original.clone())
486        {
487            return Ok(parsed);
488        }
489        let parsed = parse_multi(query).map_err(|err| RedDBError::Query(err.to_string()))?;
490        let plan = QueryPlan::new(parsed.clone(), parsed.clone(), Default::default());
491        self.inner
492            .query_cache
493            .write()
494            .insert(key, CachedPlan::new(plan).with_exact_query(query));
495        Ok(parsed)
496    }
497
498    fn execute_bound_query_expr_in_frame(
499        &self,
500        query: &str,
501        expr: QueryExpr,
502    ) -> RedDBResult<RuntimeQueryResult> {
503        let rewritten_query = super::red_schema::rewrite_virtual_names(query);
504        let execution_query = rewritten_query.as_deref().unwrap_or(query);
505        let frame = super::statement_frame::StatementExecutionFrame::build(
506            self,
507            super::statement_frame::StatementIdentity::Text(execution_query),
508        )?;
509        // Box the guards so the ~640-byte StatementFrameGuards struct lives on
510        // the heap rather than the call stack — important for recursive paths
511        // (view refresh, nested queries) where the stack can be as small as 2 MB.
512        let _frame_guards = Box::new(frame.install(self));
513        let _log_span = crate::telemetry::span::query_span(query).entered();
514
515        let expr = self.rewrite_view_refs(expr);
516        let mode = detect_mode(execution_query);
517        let control_event_specs = query_control_event_specs(&expr);
518        let _lock_guard = match frame.prepare_dispatch(self, &expr) {
519            Ok(guard) => guard,
520            Err(err) => {
521                let outcome = control_event_outcome_for_error(&err);
522                for spec in &control_event_specs {
523                    self.emit_control_event(
524                        spec.kind,
525                        outcome,
526                        spec.action,
527                        spec.resource.clone(),
528                        Some(err.to_string()),
529                        spec.fields.clone(),
530                    )?;
531                }
532                return Err(err);
533            }
534        };
535
536        self.dispatch_expr(expr, query, mode)
537    }
538
539    pub fn causal_session(&self) -> crate::runtime::CausalSession {
540        crate::runtime::CausalSession {
541            runtime: self.clone(),
542            bookmark: None,
543            wait_timeout: std::time::Duration::from_secs(5),
544        }
545    }
546
547    pub fn wait_for_bookmark(
548        &self,
549        bookmark: &crate::replication::CausalBookmark,
550        timeout: std::time::Duration,
551    ) -> RedDBResult<()> {
552        let deadline = std::time::Instant::now() + timeout;
553        loop {
554            let applied_lsn = self.local_contiguous_applied_lsn();
555            if applied_lsn >= bookmark.commit_lsn() {
556                return Ok(());
557            }
558            let now = std::time::Instant::now();
559            if now >= deadline {
560                return Err(RedDBError::InvalidOperation(format!(
561                    "timed out waiting for causal bookmark lsn {}; applied={}",
562                    bookmark.commit_lsn(),
563                    applied_lsn
564                )));
565            }
566            let remaining = deadline.saturating_duration_since(now);
567            std::thread::sleep(remaining.min(std::time::Duration::from_millis(5)));
568        }
569    }
570
571    fn local_contiguous_applied_lsn(&self) -> u64 {
572        match self.inner.db.options().replication.role {
573            crate::replication::ReplicationRole::Replica { .. } => {
574                self.config_u64("replication.last_applied_lsn", 0)
575            }
576            _ => self.replication_commit_lsn(),
577        }
578    }
579
580    #[inline(never)]
581    pub(super) fn execute_query_inner(&self, query: &str) -> RedDBResult<RuntimeQueryResult> {
582        // ── ULTRA-TURBO: autocommit `SELECT * FROM t WHERE _entity_id = N` ──
583        //
584        // Moved above every boot-cost the normal path pays (WITHIN
585        // strip, SET LOCAL parse, transaction-state reads, snapshot
586        // guard, and tracing span) because the bench's
587        // `select_point` scenario was observed at 28× vs PostgreSQL —
588        // the dominant cost wasn't the entity fetch but the ceremony
589        // before it. Only fires when there's no ambient transaction
590        // context or WITHIN override, so the snapshot install we skip
591        // truly is a no-op for this query.
592        if !has_scope_override_active()
593            && !query.trim_start().starts_with("WITHIN")
594            && !query.trim_start().starts_with("within")
595            && !self.inner.query_audit.has_rules()
596            && !self
597                .inner
598                .transaction_state
599                .in_transaction(current_connection_id())
600        {
601            if let Some(result) = self.try_fast_entity_lookup(query) {
602                return result;
603            }
604        }
605
606        // `WITHIN TENANT '<id>' [USER '<u>'] [AS ROLE '<r>'] <stmt>` —
607        // strip the prefix, push a stack-scoped override, recurse on
608        // the inner statement, pop on return. Stack lives in a
609        // thread-local but is balanced by the RAII guard, so a
610        // pool-shared connection cannot leak the override across
611        // requests and an early `?` return still pops cleanly.
612        match crate::runtime::within_clause::try_strip_within_prefix(query) {
613            Ok(Some((scope, inner))) => {
614                self.check_scope_override_privilege(&scope)?;
615                let _scope_guard = ScopeOverrideGuard::install(scope);
616                // Re-enter the inner path, NOT `execute_query`, so the
617                // slow-query lifecycle hook records exactly one row per
618                // top-level statement (the WITHIN-stripped form would
619                // double-record).
620                return self.execute_query_inner(inner);
621            }
622            Ok(None) => {}
623            Err(msg) => return Err(RedDBError::Query(msg)),
624        }
625
626        // `EXPLAIN ANALYZE <dml>` — paid truth tier. Executes the
627        // mutating statement inside a transaction that is always
628        // rolled back, then reports the actual affected row count.
629        if let Some(inner) = strip_explain_analyze_prefix(query) {
630            return self.explain_analyze_as_rows(query, inner);
631        }
632
633        // `EXPLAIN <stmt>` — introspection. Runs the planner on the
634        // inner statement (WITHOUT executing it) and returns the
635        // CanonicalLogicalNode tree as rows so the caller can see the
636        // operator shape and estimated cost. `EXPLAIN ALTER FOR ...`
637        // is a distinct schema-diff command and continues down the
638        // regular SQL path.
639        if let Some(inner) = strip_explain_prefix(query) {
640            return self.explain_as_rows(query, inner);
641        }
642
643        // `SET LOCAL TENANT '<id>'` — write the per-transaction tenant
644        // override and return. Outside a transaction the statement is
645        // an error (matches PG semantics: SET LOCAL only takes effect
646        // within an active transaction).
647        if let Some(value) = parse_set_local_tenant(query)? {
648            self.check_scope_tenant_privilege(value.as_deref())?;
649            let conn_id = current_connection_id();
650            if !self.inner.transaction_state.in_transaction(conn_id) {
651                return Err(RedDBError::Query(
652                    "SET LOCAL TENANT requires an active transaction".to_string(),
653                ));
654            }
655            self.inner
656                .transaction_state
657                .set_local_tenant(conn_id, value.clone());
658            return Ok(RuntimeQueryResult::ok_message(
659                query.to_string(),
660                &match &value {
661                    Some(id) => format!("local tenant set: {id}"),
662                    None => "local tenant cleared".to_string(),
663                },
664                "set_local_tenant",
665            ));
666        }
667
668        if super::red_schema::is_system_schema_write(query) {
669            return Err(RedDBError::Query(
670                super::red_schema::READ_ONLY_ERROR.to_string(),
671            ));
672        }
673
674        if let Some(command) = parse_runtime_vcs_command(query) {
675            let command = command?;
676            self.check_vcs_command_privilege(&command)?;
677            return self.execute_vcs_command(query, detect_mode(query), command);
678        }
679
680        if let Some(create_source) = super::analytics_source_catalog::parse_create_statement(query)?
681        {
682            return self.execute_create_analytics_source(query, create_source);
683        }
684
685        // Issue #790 — `READ METRIC <path>` is intentionally rejected at
686        // v0. The descriptor itself is readable through
687        // `red.analytics.metrics`; the *output* read returns a
688        // structured error so callers can tell "execution engine not yet
689        // built" apart from "metric does not exist".
690        if let Some(path) = super::metric_descriptor_catalog::parse_read_metric_statement(query) {
691            return Err(super::metric_descriptor_catalog::read_output_unsupported(
692                &path,
693            ));
694        }
695
696        // Issue #918 / ADR 0035 — leaderboard rank capability catalog
697        // declarations are still recognised before the general parser.
698        // Rank reads themselves are parser AST nodes, including Redis-flavor
699        // Z* sugar that desugars to the same canonical rank shapes.
700        if let Some(parsed) = super::ranking_descriptor_catalog::parse_create_ranking(query) {
701            return self.execute_create_ranking(query, parsed?);
702        }
703        if super::ranking_descriptor_catalog::parse_show_rankings(query) {
704            return self.execute_show_rankings(query);
705        }
706
707        let rewritten_query = super::red_schema::rewrite_virtual_names(query);
708        let execution_query = rewritten_query.as_deref().unwrap_or(query);
709
710        let frame = super::statement_frame::StatementExecutionFrame::build(
711            self,
712            super::statement_frame::StatementIdentity::Text(execution_query),
713        )?;
714        let _frame_guards = Box::new(frame.install(self));
715
716        // Phase 6 logging: enter a span stamped with conn_id / tenant
717        // / query_len. Every downstream tracing::info!/warn!/error!
718        // inherits these fields — no need to thread them manually
719        // through storage/scan layers. Entered AFTER the WITHIN /
720        // SET LOCAL TENANT resolution above so the span reflects the
721        // effective scope for this statement.
722        let _log_span = crate::telemetry::span::query_span(query).entered();
723
724        // ── CTE prelude (#41) — `WITH x AS (...) SELECT ... FROM x` ──
725        if let Some(rewritten) = frame.prepare_cte(execution_query)? {
726            return self.execute_query_expr(rewritten);
727        }
728
729        // ── TURBO: bypass SQL parse for SELECT * FROM x WHERE _entity_id = N ──
730        if !self.inner.query_audit.has_rules() {
731            if let Some(result) = self.try_fast_entity_lookup(execution_query) {
732                return result;
733            }
734        }
735
736        // ── Result cache: return cached result if still fresh (30s TTL) ──
737        if !self.inner.query_audit.has_rules() {
738            if let Some(result) = frame.read_result_cache(self) {
739                return Ok(result);
740            }
741        }
742
743        #[cfg(test)]
744        RESULT_CACHE_MISS_EXECUTIONS.with(|count| count.set(count.get() + 1));
745
746        let prepared = frame.prepare_statement(self, execution_query)?;
747        let mode = prepared.mode;
748        let expr = prepared.expr;
749
750        let _query_budget = if super::query_budget::is_materialized_read(&expr) {
751            Some(self.query_budget_scope()?)
752        } else {
753            None
754        };
755
756        let statement = query_expr_name(&expr);
757        let result_cache_scopes = query_expr_result_cache_scopes(&expr);
758        let control_event_specs = query_control_event_specs(&expr);
759        let query_audit_plan = query_audit_plan(&expr);
760
761        let _lock_guard = match frame.prepare_dispatch(self, &expr) {
762            Ok(guard) => guard,
763            Err(err) => {
764                let outcome = control_event_outcome_for_error(&err);
765                for spec in &control_event_specs {
766                    self.emit_control_event(
767                        spec.kind,
768                        outcome,
769                        spec.action,
770                        spec.resource.clone(),
771                        Some(err.to_string()),
772                        spec.fields.clone(),
773                    )?;
774                }
775                return Err(err);
776            }
777        };
778        let query_audit_started = std::time::Instant::now();
779
780        let query_result = match expr {
781            QueryExpr::Function(ref command) => self.execute_function(query, command),
782            QueryExpr::Graph(_) | QueryExpr::Path(_) => {
783                self.execute_materialized_graph(query, mode, statement, &expr)
784            }
785            QueryExpr::Table(table) => {
786                let table = self.resolve_table_expr_subqueries(
787                    table,
788                    &frame as &dyn super::statement_frame::ReadFrame,
789                )?;
790                // Table-valued functions (e.g. components(g)) dispatch to a
791                // read-only executor before any catalog/virtual-table routing
792                // (issue #795).
793                if let Some(TableSource::Function {
794                    name,
795                    args,
796                    named_args,
797                }) = table.source.clone()
798                {
799                    // The graph-collection form is cacheable (issue #802): the
800                    // result-cache read at the top of this function keys on the
801                    // query string, and `result_cache_scopes` carries the graph
802                    // collection (see `collect_table_source_scopes`) so a write
803                    // to it invalidates the entry. Deterministic algorithm
804                    // output is worth caching at any row count, so the write
805                    // bypasses the generic ≤5-row payload heuristic.
806                    let tvf_result = RuntimeQueryResult {
807                        query: query.to_string(),
808                        mode,
809                        statement,
810                        engine: "runtime-graph-tvf",
811                        result: self.execute_table_function(&name, &args, &named_args)?,
812                        affected_rows: 0,
813                        statement_type: "select",
814                        bookmark: None,
815                        notice: None,
816                    };
817                    frame.write_result_cache(self, &tvf_result, result_cache_scopes.clone());
818                    return Ok(tvf_result);
819                }
820                // Inline-graph TVF (issue #799): the graph is supplied by two
821                // subqueries instead of a collection reference. Unlike the
822                // graph-collection form, the result IS cacheable — its cache
823                // key is the query string (the result-cache read at the top of
824                // `execute_query_inner` keys on it) and `result_cache_scopes`
825                // already carries the `nodes`/`edges` source collections, so a
826                // write to any of them invalidates the entry.
827                if let Some(TableSource::InlineGraphFunction {
828                    name,
829                    nodes,
830                    edges,
831                    named_args,
832                }) = table.source.clone()
833                {
834                    let inline_result = RuntimeQueryResult {
835                        query: query.to_string(),
836                        mode,
837                        statement,
838                        engine: "runtime-graph-tvf-inline",
839                        result: self.execute_inline_graph_function(
840                            &name,
841                            &nodes,
842                            &edges,
843                            &named_args,
844                        )?,
845                        affected_rows: 0,
846                        statement_type: "select",
847                        bookmark: None,
848                        notice: None,
849                    };
850                    frame.write_result_cache(self, &inline_result, result_cache_scopes);
851                    return Ok(inline_result);
852                }
853                if super::red_schema::is_virtual_table(&table.table) {
854                    return Ok(RuntimeQueryResult {
855                        query: query.to_string(),
856                        mode,
857                        statement,
858                        engine: "runtime-red-schema",
859                        result: super::red_schema::red_query(
860                            self,
861                            &table.table,
862                            &table,
863                            &frame as &dyn super::statement_frame::ReadFrame,
864                        )?,
865                        affected_rows: 0,
866                        statement_type: "select",
867                        bookmark: None,
868                        notice: None,
869                    });
870                }
871
872                // `<graph>.<output>` analytics virtual view (issue #800).
873                // Recomputed on demand — intentionally not result-cached, so it
874                // always reflects the current graph data.
875                if let Some(view_result) = self.try_resolve_analytics_view(
876                    &table,
877                    &frame as &dyn super::statement_frame::ReadFrame,
878                )? {
879                    return Ok(RuntimeQueryResult {
880                        query: query.to_string(),
881                        mode,
882                        statement,
883                        engine: "runtime-graph-analytics-view",
884                        result: view_result,
885                        affected_rows: 0,
886                        statement_type: "select",
887                        bookmark: None,
888                        notice: None,
889                    });
890                }
891
892                if let Some(result) = self.execute_probabilistic_select(&table)? {
893                    return Ok(RuntimeQueryResult {
894                        query: query.to_string(),
895                        mode,
896                        statement,
897                        engine: "runtime-probabilistic",
898                        result,
899                        affected_rows: 0,
900                        statement_type: "select",
901                        bookmark: None,
902                        notice: None,
903                    });
904                }
905
906                // Foreign-table intercept (Phase 3.2.2 PG parity).
907                //
908                // When the referenced table matches a `CREATE FOREIGN TABLE`
909                // registration, short-circuit into the FDW scan. Phase 3.2
910                // wrappers don't yet support pushdown, so filters/projections
911                // apply post-scan via `apply_foreign_table_filters` — good
912                // enough for correctness; perf work lands in 3.2.3.
913                if self.inner.foreign_tables.is_foreign_table(&table.table) {
914                    let records = self
915                        .inner
916                        .foreign_tables
917                        .scan(&table.table)
918                        .map_err(|e| RedDBError::Internal(e.to_string()))?;
919                    let result = apply_foreign_table_filters(records, &table);
920                    return Ok(RuntimeQueryResult {
921                        query: query.to_string(),
922                        mode,
923                        statement,
924                        engine: "runtime-fdw",
925                        result,
926                        affected_rows: 0,
927                        statement_type: "select",
928                        bookmark: None,
929                        notice: None,
930                    });
931                }
932
933                // Row-Level Security enforcement (Phase 2.5.2 PG parity).
934                //
935                // When RLS is enabled on this table, fetch every policy
936                // that applies to the current (role, SELECT) pair and
937                // fold them into the query's WHERE clause: policies
938                // OR-combine (any of them admitting the row is enough),
939                // then AND into the caller's existing filter.
940                //
941                // Anonymous callers (no thread-local identity) pass
942                // `role = None`; policies with a specific `TO role`
943                // clause skip, but `TO PUBLIC` policies still apply.
944                //
945                // When `inject_rls_filters` returns `None` the table has
946                // RLS enabled but no policy admits the caller's role —
947                // short-circuit with an empty result set instead of
948                // synthesising a contradiction filter.
949                let Some(table_with_rls) = self.authorize_relational_table_select(
950                    table,
951                    &frame as &dyn super::statement_frame::ReadFrame,
952                )?
953                else {
954                    let empty = crate::storage::query::unified::UnifiedResult::empty();
955                    return Ok(RuntimeQueryResult {
956                        query: query.to_string(),
957                        mode,
958                        statement,
959                        engine: "runtime-table-rls",
960                        result: empty,
961                        affected_rows: 0,
962                        statement_type: "select",
963                        bookmark: None,
964                        notice: None,
965                    });
966                };
967                Ok(RuntimeQueryResult {
968                    query: query.to_string(),
969                    mode,
970                    statement,
971                    engine: "runtime-table",
972                    // #885: lend the frame-owned row-buffer arena to the
973                    // streaming path so chunk buffers are reused across
974                    // this statement's chunk-fetches instead of allocated
975                    // fresh per chunk. This is the table-query dispatch
976                    // that receives a frame-owned arena; the prepared and
977                    // subquery dispatch paths keep `None` for the arena.
978                    result: execute_runtime_table_query_in(
979                        &self.inner.db,
980                        &table_with_rls,
981                        Some(&self.inner.index_store),
982                        Some(frame.row_arena()),
983                    )?,
984                    affected_rows: 0,
985                    statement_type: "select",
986                    bookmark: None,
987                    notice: None,
988                })
989            }
990            QueryExpr::Join(join) => {
991                // Fold per-table RLS filters into each `QueryExpr::Table`
992                // leaf of the join tree before executing. Without this
993                // the join executor scans both tables raw and ignores
994                // policies — a `WITHIN TENANT 'x'` against a join of
995                // two tenant-scoped tables would leak cross-tenant rows.
996                // When any leaf has RLS enabled and zero matching policy,
997                // short-circuit to an empty join result instead of
998                // emitting a contradiction filter.
999                let join_with_rls = match self.authorize_relational_join_select(
1000                    join,
1001                    &frame as &dyn super::statement_frame::ReadFrame,
1002                )? {
1003                    Some(j) => j,
1004                    None => {
1005                        return Ok(RuntimeQueryResult {
1006                            query: query.to_string(),
1007                            mode,
1008                            statement,
1009                            engine: "runtime-join-rls",
1010                            result: crate::storage::query::unified::UnifiedResult::empty(),
1011                            affected_rows: 0,
1012                            statement_type: "select",
1013                            bookmark: None,
1014                            notice: None,
1015                        });
1016                    }
1017                };
1018                Ok(RuntimeQueryResult {
1019                    query: query.to_string(),
1020                    mode,
1021                    statement,
1022                    engine: "runtime-join",
1023                    result: execute_runtime_join_query(self, &join_with_rls)?,
1024                    affected_rows: 0,
1025                    statement_type: "select",
1026                    bookmark: None,
1027                    notice: None,
1028                })
1029            }
1030            QueryExpr::Vector(vector) => Ok(RuntimeQueryResult {
1031                query: query.to_string(),
1032                mode,
1033                statement,
1034                engine: "runtime-vector",
1035                result: execute_runtime_vector_query(self, &vector)?,
1036                affected_rows: 0,
1037                statement_type: "select",
1038                bookmark: None,
1039                notice: None,
1040            }),
1041            QueryExpr::Hybrid(hybrid) => Ok(RuntimeQueryResult {
1042                query: query.to_string(),
1043                mode,
1044                statement,
1045                engine: "runtime-hybrid",
1046                result: execute_runtime_hybrid_query(self, &hybrid)?,
1047                affected_rows: 0,
1048                statement_type: "select",
1049                bookmark: None,
1050                notice: None,
1051            }),
1052            QueryExpr::RankOf(ref rank) => self.execute_rank_of(query, rank),
1053            QueryExpr::ApproxRankOf(ref rank) => self.execute_approx_rank_of(query, rank),
1054            QueryExpr::RankRange(ref range) => self.execute_rank_range(query, range),
1055            // DML execution
1056            QueryExpr::Insert(ref insert) if super::red_schema::is_virtual_table(&insert.table) => {
1057                Err(RedDBError::Query(
1058                    super::red_schema::READ_ONLY_ERROR.to_string(),
1059                ))
1060            }
1061            QueryExpr::Update(ref update) if super::red_schema::is_virtual_table(&update.table) => {
1062                Err(RedDBError::Query(
1063                    super::red_schema::READ_ONLY_ERROR.to_string(),
1064                ))
1065            }
1066            QueryExpr::Delete(ref delete) if super::red_schema::is_virtual_table(&delete.table) => {
1067                Err(RedDBError::Query(
1068                    super::red_schema::READ_ONLY_ERROR.to_string(),
1069                ))
1070            }
1071            QueryExpr::Insert(ref insert) => self
1072                .with_deferred_store_wal_for_dml(self.insert_may_emit_events(insert), || {
1073                    self.execute_insert(query, insert)
1074                }),
1075            QueryExpr::Update(ref update) => self
1076                .with_deferred_store_wal_for_dml(self.update_may_emit_events(update), || {
1077                    self.execute_update(query, update)
1078                }),
1079            QueryExpr::Delete(ref delete) => self
1080                .with_deferred_store_wal_for_dml(self.delete_may_emit_events(delete), || {
1081                    self.execute_delete(query, delete)
1082                }),
1083            // DDL execution
1084            QueryExpr::CreateTable(ref create) => self.execute_create_table(query, create),
1085            QueryExpr::CreateCollection(ref create) => {
1086                self.execute_create_collection(query, create)
1087            }
1088            QueryExpr::CreateVector(ref create) => self.execute_create_vector(query, create),
1089            QueryExpr::DropTable(ref drop_tbl) => self.execute_drop_table(query, drop_tbl),
1090            QueryExpr::DropGraph(ref drop_graph) => self.execute_drop_graph(query, drop_graph),
1091            QueryExpr::DropVector(ref drop_vector) => self.execute_drop_vector(query, drop_vector),
1092            QueryExpr::DropDocument(ref drop_document) => {
1093                self.execute_drop_document(query, drop_document)
1094            }
1095            QueryExpr::DropKv(ref drop_kv) => self.execute_drop_kv(query, drop_kv),
1096            QueryExpr::DropCollection(ref drop_collection) => {
1097                self.execute_drop_collection(query, drop_collection)
1098            }
1099            QueryExpr::Truncate(ref truncate) => self.execute_truncate(query, truncate),
1100            QueryExpr::AlterTable(ref alter) => self.execute_alter_table(query, alter),
1101            QueryExpr::CreateVcsRef(ref create) => self.execute_create_vcs_ref(query, create),
1102            QueryExpr::DropVcsRef(ref drop_ref) => self.execute_drop_vcs_ref(query, drop_ref),
1103            QueryExpr::ForkStore(ref fork) => self.execute_fork_store(query, fork),
1104            QueryExpr::PromoteFork(ref promote_fork) => {
1105                self.execute_promote_fork(query, promote_fork)
1106            }
1107            QueryExpr::DropFork(ref drop_fork) => self.execute_drop_fork(query, drop_fork),
1108            QueryExpr::ExplainAlter(ref explain) => self.execute_explain_alter(query, explain),
1109            // Graph analytics commands
1110            QueryExpr::GraphCommand(ref cmd) => self.execute_graph_command(query, cmd),
1111            // Search commands
1112            QueryExpr::SearchCommand(ref cmd) => self.execute_search_command(query, cmd),
1113            // ASK: RAG query with LLM synthesis
1114            QueryExpr::Ask(ref ask) => self.execute_ask(query, ask),
1115            QueryExpr::CreateIndex(ref create_idx) => self.execute_create_index(query, create_idx),
1116            QueryExpr::DropIndex(ref drop_idx) => self.execute_drop_index(query, drop_idx),
1117            QueryExpr::ProbabilisticCommand(ref cmd) => {
1118                self.execute_probabilistic_command(query, cmd)
1119            }
1120            // Time-series DDL
1121            QueryExpr::CreateTimeSeries(ref ts) => self.execute_create_timeseries(query, ts),
1122            QueryExpr::CreateMetric(ref metric) => self.execute_create_metric(query, metric),
1123            QueryExpr::AlterMetric(ref alter) => self.execute_alter_metric(query, alter),
1124            QueryExpr::CreateSlo(ref slo) => self.execute_create_slo(query, slo),
1125            QueryExpr::DropTimeSeries(ref ts) => self.execute_drop_timeseries(query, ts),
1126            // Queue DDL and commands
1127            QueryExpr::CreateQueue(ref q) => self.execute_create_queue(query, q),
1128            QueryExpr::AlterQueue(ref q) => self.execute_alter_queue(query, q),
1129            QueryExpr::DropQueue(ref q) => self.execute_drop_queue(query, q),
1130            QueryExpr::QueueSelect(ref q) => self.execute_queue_select(query, q),
1131            QueryExpr::QueueCommand(ref cmd) => self
1132                .with_deferred_store_wal_if_transaction(|| self.execute_queue_command(query, cmd)),
1133            QueryExpr::EventsBackfill(ref backfill) => {
1134                self.execute_events_backfill(query, backfill)
1135            }
1136            QueryExpr::EventsBackfillStatus { ref collection } => Err(RedDBError::Query(format!(
1137                "EVENTS BACKFILL STATUS for '{collection}' is not implemented in this slice"
1138            ))),
1139            QueryExpr::KvCommand(ref cmd) => self.execute_kv_command(query, cmd),
1140            QueryExpr::ConfigCommand(ref cmd) => self.execute_config_command(query, cmd),
1141            QueryExpr::CreateTree(ref tree) => self.execute_create_tree(query, tree),
1142            QueryExpr::DropTree(ref tree) => self.execute_drop_tree(query, tree),
1143            QueryExpr::TreeCommand(ref cmd) => self.execute_tree_command(query, cmd),
1144            // SET CONFIG key = value
1145            QueryExpr::SetConfig { ref key, ref value } => {
1146                if key.starts_with("red.secret.") {
1147                    return Err(RedDBError::Query(
1148                        "red.secret.* is reserved for vault secrets; use SET SECRET".to_string(),
1149                    ));
1150                }
1151                if key.starts_with("red.secrets.") {
1152                    return Err(RedDBError::Query(
1153                        "red.secrets.* is reserved for vault secrets; use SET SECRET".to_string(),
1154                    ));
1155                }
1156                if key.starts_with("red.") {
1157                    return Err(RedDBError::Query(format!(
1158                        "config key `{key}` is invalid inside red.config: omit the redundant `red.` prefix"
1159                    )));
1160                }
1161                // ADR-0068 §5 clean break: reject the removed AI config keys
1162                // (old `default.*` provider/model and per-alias base_url shape)
1163                // with a didactic error naming the replacement key.
1164                crate::ai::validate_ai_config_key_on_write(key)?;
1165                // The managed-config guardrail runs first: it owns the
1166                // didactic error and the `config:write` control event for
1167                // registry-managed keys. The generic capability gate below
1168                // then covers every other key.
1169                match self.check_managed_config_write_for_set_config(key) {
1170                    Err(err) => Err(err),
1171                    Ok(()) => {
1172                        self.check_config_write_privilege(key)?;
1173                        let store = self.inner.db.store();
1174                        let json_val = match value {
1175                            Value::Text(s) => crate::serde_json::Value::String(s.to_string()),
1176                            Value::Integer(n) => crate::serde_json::Value::Number(*n as f64),
1177                            Value::Float(n) => crate::serde_json::Value::Number(*n),
1178                            Value::Boolean(b) => crate::serde_json::Value::Bool(*b),
1179                            _ => crate::serde_json::Value::String(value.to_string()),
1180                        };
1181                        store
1182                            .try_set_config_tree(key, &json_val)
1183                            .map_err(RedDBError::from)?;
1184                        update_current_config_value(key, value.clone());
1185                        // Config changes can flip runtime behavior mid-session
1186                        // (auto_decrypt, auto_encrypt, etc.) — invalidate the
1187                        // result cache so subsequent reads re-execute against
1188                        // the new config.
1189                        self.invalidate_result_cache();
1190                        Ok(RuntimeQueryResult::ok_message(
1191                            query.to_string(),
1192                            &format!("config set: {key}"),
1193                            "set",
1194                        ))
1195                    }
1196                }
1197            }
1198            // SET SECRET key = value
1199            QueryExpr::SetSecret { ref key, ref value } => {
1200                if key.starts_with("red.config.") {
1201                    return Err(RedDBError::Query(
1202                        "red.config.* is reserved for config; use SET CONFIG".to_string(),
1203                    ));
1204                }
1205                reject_engine_internal_vault_key(key)?;
1206                if key.starts_with("red.secret.") {
1207                    self.set_internal_provider_secret(
1208                        key,
1209                        if value.is_null() {
1210                            None
1211                        } else {
1212                            Some(secret_sql_value_to_string(value)?)
1213                        },
1214                    )?;
1215                    return Ok(RuntimeQueryResult::ok_message(
1216                        query.to_string(),
1217                        &format!("secret set: {key}"),
1218                        "set_secret",
1219                    ));
1220                }
1221                let value = if value.is_null() {
1222                    None
1223                } else {
1224                    Some(secret_sql_value_to_string(value)?)
1225                };
1226                self.set_user_secret(key, value.clone())?;
1227                update_current_secret_value(key, value);
1228                Ok(RuntimeQueryResult::ok_message(
1229                    query.to_string(),
1230                    &format!("secret set: {key}"),
1231                    "set_secret",
1232                ))
1233            }
1234            // DELETE SECRET key
1235            QueryExpr::DeleteSecret { ref key } => {
1236                reject_engine_internal_vault_key(key)?;
1237                if key.starts_with("red.secret.") {
1238                    self.set_internal_provider_secret(key, None)?;
1239                    return Ok(RuntimeQueryResult::ok_message(
1240                        query.to_string(),
1241                        &format!("secret deleted: {key}"),
1242                        "delete_secret",
1243                    ));
1244                }
1245                self.set_user_secret(key, None)?;
1246                update_current_secret_value(key, None);
1247                Ok(RuntimeQueryResult::ok_message(
1248                    query.to_string(),
1249                    &format!("secret deleted: {key}"),
1250                    "delete_secret",
1251                ))
1252            }
1253            // SET KV key = value — plain (non-encrypted) user KV entry (#1602)
1254            QueryExpr::SetKv { ref key, ref value } => {
1255                let auth_store = self.inner.auth_store.read().clone().ok_or_else(|| {
1256                    RedDBError::Query("SET KV requires an auth store".to_string())
1257                })?;
1258                self.check_kv_write_privilege(&auth_store, key)?;
1259                // `SET KV k = NULL` deletes, mirroring `SET SECRET`.
1260                if matches!(value, Value::Null) {
1261                    auth_store.plain_kv_delete(key);
1262                    update_current_kv_value(key, None);
1263                    self.invalidate_result_cache();
1264                    return Ok(RuntimeQueryResult::ok_message(
1265                        query.to_string(),
1266                        &format!("kv deleted: {key}"),
1267                        "delete_kv",
1268                    ));
1269                }
1270                let value = secret_sql_value_to_string(value)?;
1271                auth_store.plain_kv_set(key.clone(), value.clone());
1272                update_current_kv_value(key, Some(value));
1273                self.invalidate_result_cache();
1274                Ok(RuntimeQueryResult::ok_message(
1275                    query.to_string(),
1276                    &format!("kv set: {key}"),
1277                    "set_kv",
1278                ))
1279            }
1280            // DELETE KV key
1281            QueryExpr::DeleteKv { ref key } => {
1282                let auth_store = self.inner.auth_store.read().clone().ok_or_else(|| {
1283                    RedDBError::Query("DELETE KV requires an auth store".to_string())
1284                })?;
1285                self.check_kv_write_privilege(&auth_store, key)?;
1286                let deleted = auth_store.plain_kv_delete(key);
1287                if deleted {
1288                    update_current_kv_value(key, None);
1289                }
1290                self.invalidate_result_cache();
1291                Ok(RuntimeQueryResult::ok_message(
1292                    query.to_string(),
1293                    &format!("kv deleted: {key}"),
1294                    if deleted {
1295                        "delete_kv"
1296                    } else {
1297                        "delete_kv_not_found"
1298                    },
1299                ))
1300            }
1301            QueryExpr::Scrub { background, budget } => {
1302                self.execute_scrub_query(query, mode, background, budget)
1303            }
1304            // SHOW SECRET[S] [prefix]
1305            QueryExpr::ShowSecrets { ref prefix } => {
1306                let result = self.show_user_secrets(prefix.as_deref())?;
1307                Ok(RuntimeQueryResult {
1308                    query: query.to_string(),
1309                    mode,
1310                    statement: "show_secrets",
1311                    engine: "runtime-secret",
1312                    result,
1313                    affected_rows: 0,
1314                    statement_type: "select",
1315                    bookmark: None,
1316                    notice: None,
1317                })
1318            }
1319            // SHOW CONFIG [prefix] [AS JSON|FORMAT JSON]
1320            QueryExpr::ShowConfig {
1321                ref prefix,
1322                as_json,
1323            } => {
1324                if let Some(prefix) = prefix {
1325                    super::impl_config::validate_red_config_content_key("red.config", prefix)?;
1326                }
1327                self.check_config_read_privilege()?;
1328                let store = self.inner.db.store();
1329                let all_collections = store.list_collections();
1330                if !all_collections.contains(&"red.config".to_string()) {
1331                    if as_json {
1332                        return Ok(show_config_json_result(
1333                            query,
1334                            mode,
1335                            prefix,
1336                            crate::serde_json::Value::Object(crate::serde_json::Map::new()),
1337                        ));
1338                    }
1339                    let result = UnifiedResult::with_columns(vec!["key".into(), "value".into()]);
1340                    return Ok(RuntimeQueryResult {
1341                        query: query.to_string(),
1342                        mode,
1343                        statement: "show_config",
1344                        engine: "runtime-config",
1345                        result,
1346                        affected_rows: 0,
1347                        statement_type: "select",
1348                        bookmark: None,
1349                        notice: None,
1350                    });
1351                }
1352                let manager = store
1353                    .get_collection("red.config")
1354                    .ok_or_else(|| RedDBError::NotFound("red.config".to_string()))?;
1355                let entities = manager.query_all(|_| true);
1356                let mut latest = std::collections::BTreeMap::<String, (u64, Value, Value)>::new();
1357                for entity in entities {
1358                    if let EntityData::Row(ref row) = entity.data {
1359                        if let Some(ref named) = row.named {
1360                            let key_val = named.get("key").cloned().unwrap_or(Value::Null);
1361                            let val = named.get("value").cloned().unwrap_or(Value::Null);
1362                            let key_str = match &key_val {
1363                                Value::Text(s) => s.as_ref(),
1364                                _ => continue,
1365                            };
1366                            if key_str == super::function_catalog::REGISTRY_KEY {
1367                                continue;
1368                            }
1369                            if let Some(ref pfx) = prefix {
1370                                if !key_str.starts_with(pfx.as_str()) {
1371                                    continue;
1372                                }
1373                            }
1374                            let entity_id = entity.id.raw();
1375                            match latest.get(key_str) {
1376                                Some((prev_id, _, _)) if *prev_id > entity_id => {}
1377                                _ => {
1378                                    latest.insert(key_str.to_string(), (entity_id, key_val, val));
1379                                }
1380                            }
1381                        }
1382                    }
1383                }
1384                if as_json {
1385                    let mut tree = crate::serde_json::Value::Object(crate::serde_json::Map::new());
1386                    for (key, (_, _, val)) in latest {
1387                        let relative = match prefix {
1388                            Some(pfx) if key == *pfx => "",
1389                            Some(pfx) => key
1390                                .strip_prefix(pfx.as_str())
1391                                .and_then(|tail| tail.strip_prefix('.'))
1392                                .unwrap_or(key.as_str()),
1393                            None => key.as_str(),
1394                        };
1395                        insert_config_json_path(&mut tree, relative, val.to_json());
1396                    }
1397                    return Ok(show_config_json_result(query, mode, prefix, tree));
1398                }
1399                let mut result = UnifiedResult::with_columns(vec!["key".into(), "value".into()]);
1400                for (_, key_val, val) in latest.into_values() {
1401                    let mut record = UnifiedRecord::new();
1402                    record.set("key", key_val);
1403                    record.set("value", val);
1404                    result.push(record);
1405                }
1406                Ok(RuntimeQueryResult {
1407                    query: query.to_string(),
1408                    mode,
1409                    statement: "show_config",
1410                    engine: "runtime-config",
1411                    result,
1412                    affected_rows: 0,
1413                    statement_type: "select",
1414                    bookmark: None,
1415                    notice: None,
1416                })
1417            }
1418            // Session-local multi-tenancy handle (Phase 2.5.3).
1419            //
1420            // SET TENANT 'id' / SET TENANT NULL / RESET TENANT — writes
1421            // the thread-local; SHOW TENANT returns it. Paired with the
1422            // CURRENT_TENANT() scalar for use in RLS policies.
1423            QueryExpr::SetTenant(ref value) => {
1424                self.check_set_tenant_privilege(value.as_deref())?;
1425                match value {
1426                    Some(id) => set_current_tenant(id.clone()),
1427                    None => clear_current_tenant(),
1428                }
1429                Ok(RuntimeQueryResult::ok_message(
1430                    query.to_string(),
1431                    &match value {
1432                        Some(id) => format!("tenant set: {id}"),
1433                        None => "tenant cleared".to_string(),
1434                    },
1435                    "set_tenant",
1436                ))
1437            }
1438            QueryExpr::ShowTenant => {
1439                let mut result = UnifiedResult::with_columns(vec!["tenant".into()]);
1440                let mut record = UnifiedRecord::new();
1441                record.set(
1442                    "tenant",
1443                    current_tenant().map(Value::text).unwrap_or(Value::Null),
1444                );
1445                result.push(record);
1446                Ok(RuntimeQueryResult {
1447                    query: query.to_string(),
1448                    mode,
1449                    statement: "show_tenant",
1450                    engine: "runtime-tenant",
1451                    result,
1452                    affected_rows: 0,
1453                    statement_type: "select",
1454                    bookmark: None,
1455                    notice: None,
1456                })
1457            }
1458            // Transaction control (Phase 2.3 PG parity).
1459            //
1460            // BEGIN allocates a real `Xid` and stores a `TxnContext` keyed by
1461            // the current connection's id. COMMIT/ROLLBACK release it through
1462            // the `SnapshotManager` so future snapshots see the correct set of
1463            // active/aborted transactions.
1464            //
1465            // Tuple stamping (xmin/xmax) and read-path visibility filtering
1466            // land in Phase 2.3.2 — this dispatch only manages the snapshot
1467            // registry. Statements running outside a TxnContext still behave
1468            // as autocommit (xid=0 → visible to every snapshot).
1469            QueryExpr::TransactionControl(ref ctl) => {
1470                use crate::storage::transaction::IsolationLevel;
1471                use reddb_rql::ast::TxnControl;
1472
1473                // Phase 2.3 keys transactions by a thread-local connection id.
1474                // The stdio/gRPC paths wire a real per-connection id later;
1475                // for embedded use (one RedDBRuntime per process-ish caller)
1476                // we fall back to a deterministic placeholder.
1477                let conn_id = current_connection_id();
1478
1479                let (kind, msg) = match ctl {
1480                    TxnControl::Begin(requested_isolation) => {
1481                        if self.inner.transaction_state.in_transaction(conn_id) {
1482                            return Err(RedDBError::Query("transaction already active".into()));
1483                        }
1484                        let isolation = requested_isolation
1485                            .map(IsolationLevel::from)
1486                            .unwrap_or(IsolationLevel::SnapshotIsolation);
1487                        let xid = self.inner.transaction_state.begin(conn_id, isolation);
1488                        ("begin", format!("BEGIN — xid={xid} (snapshot isolation)"))
1489                    }
1490                    TxnControl::Commit => {
1491                        let has_vault_writes = self
1492                            .inner
1493                            .pending_vault_writes
1494                            .read()
1495                            .get(&conn_id)
1496                            .is_some_and(|writes| !writes.is_empty());
1497                        let store = self.inner.db.store();
1498                        let _vault_guard =
1499                            has_vault_writes.then(|| store.vault_publication_lock.lock());
1500                        let context = self.inner.transaction_state.commit(conn_id, |ctx| {
1501                            let mut own_xids = std::collections::HashSet::new();
1502                            own_xids.insert(ctx.xid);
1503                            for (_, sub) in &ctx.savepoints {
1504                                own_xids.insert(*sub);
1505                            }
1506                            for sub in &ctx.released_sub_xids {
1507                                own_xids.insert(*sub);
1508                            }
1509                            if let Err(err) = self.check_table_row_write_conflicts(
1510                                conn_id,
1511                                &ctx.snapshot,
1512                                &own_xids,
1513                                ctx.isolation,
1514                            ) {
1515                                let undo = self.revive_pending_versioned_updates(conn_id);
1516                                self.revive_pending_tombstones(conn_id);
1517                                self.discard_pending_kv_watch_events(conn_id);
1518                                self.discard_pending_queue_wakes(conn_id);
1519                                self.discard_pending_store_wal_actions(conn_id);
1520                                self.release_pending_claim_locks(conn_id);
1521                                undo?;
1522                                return Err(err);
1523                            }
1524                            if let Err(err) = self.check_queue_dedup_write_conflicts(
1525                                conn_id,
1526                                &ctx.snapshot,
1527                                &own_xids,
1528                            ) {
1529                                let undo = self.revive_pending_versioned_updates(conn_id);
1530                                self.revive_pending_tombstones(conn_id);
1531                                self.discard_pending_queue_dedup(conn_id);
1532                                self.discard_pending_kv_watch_events(conn_id);
1533                                self.discard_pending_queue_wakes(conn_id);
1534                                self.discard_pending_store_wal_actions(conn_id);
1535                                self.release_pending_claim_locks(conn_id);
1536                                undo?;
1537                                return Err(err);
1538                            }
1539                            if let Err(err) = self.prepare_pending_vault_writes(conn_id, ctx) {
1540                                let undo = self.revive_pending_versioned_updates(conn_id);
1541                                self.revive_pending_tombstones(conn_id);
1542                                self.discard_pending_queue_dedup(conn_id);
1543                                self.discard_pending_kv_watch_events(conn_id);
1544                                self.discard_pending_queue_wakes(conn_id);
1545                                self.discard_pending_store_wal_actions(conn_id);
1546                                self.release_pending_claim_locks(conn_id);
1547                                undo?;
1548                                return Err(err);
1549                            }
1550                            // Validate before taking the publication fence:
1551                            // conflict rollback can acquire index topology.
1552                            // Retain the fence through WAL and visibility so a
1553                            // checkpoint cannot discard a durable commit's WAL.
1554                            let publication = store.vault_publication_lock.lock();
1555                            self.restore_pending_write_stamps(conn_id);
1556                            if let Err(err) = self.flush_pending_store_wal_actions(conn_id) {
1557                                drop(publication);
1558                                let undo = self.revive_pending_versioned_updates(conn_id);
1559                                self.revive_pending_tombstones(conn_id);
1560                                self.discard_pending_queue_dedup(conn_id);
1561                                self.discard_pending_kv_watch_events(conn_id);
1562                                self.discard_pending_queue_wakes(conn_id);
1563                                self.release_pending_claim_locks(conn_id);
1564                                undo?;
1565                                return Err(err);
1566                            }
1567                            #[cfg(test)]
1568                            self.index_store_ref().mutation_test_hook(
1569                                "",
1570                                super::index_store::MutationTestPhase::TransactionWalCommitted,
1571                            );
1572                            Ok(publication)
1573                        });
1574                        if context.is_err() {
1575                            self.discard_pending_vault_writes(conn_id)?;
1576                        }
1577                        let context = context?;
1578                        match context {
1579                            Some(ctx) => {
1580                                self.finalize_pending_vault_writes(conn_id);
1581                                self.finalize_pending_versioned_updates(conn_id);
1582                                self.finalize_pending_tombstones(conn_id);
1583                                self.finalize_pending_queue_dedup(conn_id);
1584                                self.finalize_pending_kv_watch_events(conn_id);
1585                                self.finalize_pending_queue_wakes(conn_id);
1586                                self.release_pending_claim_locks(conn_id);
1587                                self.recover_transactional_replication().map_err(|error| {
1588                                    RedDBError::Internal(format!(
1589                                        "commit is locally durable; replication publication is pending: {error}"
1590                                    ))
1591                                })?;
1592                                ("commit", format!("COMMIT — xid={} committed", ctx.xid))
1593                            }
1594                            None => (
1595                                "commit",
1596                                "COMMIT outside transaction — no-op (autocommit)".to_string(),
1597                            ),
1598                        }
1599                    }
1600                    TxnControl::Rollback => match self.rollback_connection(conn_id)? {
1601                        Some(ctx) => ("rollback", format!("ROLLBACK — xid={} aborted", ctx.xid)),
1602                        None => (
1603                            "rollback",
1604                            "ROLLBACK outside transaction — no-op (autocommit)".to_string(),
1605                        ),
1606                    },
1607                    // Phase 2.3.2e: savepoints map onto sub-xids. Each
1608                    // SAVEPOINT allocates a fresh xid and pushes it
1609                    // onto the per-txn stack so subsequent writes can
1610                    // be selectively rolled back. RELEASE pops without
1611                    // aborting; ROLLBACK TO aborts the sub-xid (and
1612                    // any nested ones) + revives their tombstones.
1613                    TxnControl::Savepoint(name) => {
1614                        match self.inner.transaction_state.savepoint(conn_id, name) {
1615                            Some(sub) => ("savepoint", format!("SAVEPOINT {name} — sub_xid={sub}")),
1616                            None => (
1617                                "savepoint",
1618                                "SAVEPOINT outside transaction — no-op".to_string(),
1619                            ),
1620                        }
1621                    }
1622                    TxnControl::ReleaseSavepoint(name) => {
1623                        match self
1624                            .inner
1625                            .transaction_state
1626                            .release_savepoint(conn_id, name)?
1627                        {
1628                            Some(released) => (
1629                                "release_savepoint",
1630                                format!("RELEASE SAVEPOINT {name} — {released} level(s)"),
1631                            ),
1632                            None => (
1633                                "release_savepoint",
1634                                "RELEASE outside transaction — no-op".to_string(),
1635                            ),
1636                        }
1637                    }
1638                    TxnControl::RollbackToSavepoint(name) => {
1639                        match self
1640                            .inner
1641                            .transaction_state
1642                            .rollback_to_savepoint_with(conn_id, name, |rollback| {
1643                                self.rollback_vault_savepoint(conn_id, rollback.savepoint_xid);
1644                                let reverted_updates = self.revive_versioned_updates_since(
1645                                    conn_id,
1646                                    rollback.savepoint_xid,
1647                                );
1648                                let revived =
1649                                    self.revive_tombstones_since(conn_id, rollback.savepoint_xid);
1650                                reverted_updates.map(|updates| (updates, revived))
1651                            })?
1652                        {
1653                            Some((rollback, (reverted_updates, revived))) => {
1654                                (
1655                                    "rollback_to_savepoint",
1656                                    format!(
1657                                        "ROLLBACK TO SAVEPOINT {name} — aborted {} sub_xid(s), reverted {reverted_updates} update(s), revived {revived} tombstone(s)",
1658                                        rollback.aborted_xids.len(),
1659                                    ),
1660                                )
1661                            }
1662                            None => (
1663                                "rollback_to_savepoint",
1664                                "ROLLBACK TO outside transaction — no-op".to_string(),
1665                            ),
1666                        }
1667                    }
1668                };
1669                Ok(RuntimeQueryResult::ok_message(
1670                    query.to_string(),
1671                    &msg,
1672                    kind,
1673                ))
1674            }
1675            // Schema + Sequence DDL (Phase 1.3 PG parity).
1676            //
1677            // Schemas are lightweight logical namespaces: a CREATE SCHEMA call
1678            // just registers the name in `red.config` under `schema.{name}`.
1679            // Table lookups still happen by collection name; clients using
1680            // `schema.table` qualified names collapse to collection `schema.table`.
1681            //
1682            // Sequences persist a 64-bit counter + metadata (start, increment)
1683            // in `red.config` under `sequence.{name}.*`. Scalar callers
1684            // `nextval('name')` / `currval('name')` arrive with the MVCC phase
1685            // once we have a proper mutating-function dispatch path; for now the
1686            // DDL just establishes the catalog entry so clients don't error.
1687            QueryExpr::CreateSchema(ref q) => {
1688                let store = self.inner.db.store();
1689                let key = format!("schema.{}", q.name);
1690                if store.get_config(&key).is_some() {
1691                    if q.if_not_exists {
1692                        return Ok(RuntimeQueryResult::ok_message(
1693                            query.to_string(),
1694                            &format!("schema {} already exists — skipped", q.name),
1695                            "create_schema",
1696                        ));
1697                    }
1698                    return Err(RedDBError::Internal(format!(
1699                        "schema {} already exists",
1700                        q.name
1701                    )));
1702                }
1703                store.set_config_tree(&key, &crate::serde_json::Value::Bool(true));
1704                Ok(RuntimeQueryResult::ok_message(
1705                    query.to_string(),
1706                    &format!("schema {} created", q.name),
1707                    "create_schema",
1708                ))
1709            }
1710            QueryExpr::DropSchema(ref q) => {
1711                let store = self.inner.db.store();
1712                let key = format!("schema.{}", q.name);
1713                let existed = store.get_config(&key).is_some();
1714                if !existed && !q.if_exists {
1715                    return Err(RedDBError::Internal(format!(
1716                        "schema {} does not exist",
1717                        q.name
1718                    )));
1719                }
1720                // Remove marker from red.config via set to null.
1721                store.set_config_tree(&key, &crate::serde_json::Value::Null);
1722                let suffix = if q.cascade {
1723                    " (CASCADE accepted — tables untouched)"
1724                } else {
1725                    ""
1726                };
1727                Ok(RuntimeQueryResult::ok_message(
1728                    query.to_string(),
1729                    &format!("schema {} dropped{}", q.name, suffix),
1730                    "drop_schema",
1731                ))
1732            }
1733            QueryExpr::CreateSequence(ref q) => {
1734                let store = self.inner.db.store();
1735                let base = format!("sequence.{}", q.name);
1736                let start_key = format!("{base}.start");
1737                let incr_key = format!("{base}.increment");
1738                let curr_key = format!("{base}.current");
1739                if store.get_config(&start_key).is_some() {
1740                    if q.if_not_exists {
1741                        return Ok(RuntimeQueryResult::ok_message(
1742                            query.to_string(),
1743                            &format!("sequence {} already exists — skipped", q.name),
1744                            "create_sequence",
1745                        ));
1746                    }
1747                    return Err(RedDBError::Internal(format!(
1748                        "sequence {} already exists",
1749                        q.name
1750                    )));
1751                }
1752                // Persist start + increment, and set current so the first
1753                // nextval returns `start`.
1754                let initial_current = q.start - q.increment;
1755                store.set_config_tree(
1756                    &start_key,
1757                    &crate::serde_json::Value::Number(q.start as f64),
1758                );
1759                store.set_config_tree(
1760                    &incr_key,
1761                    &crate::serde_json::Value::Number(q.increment as f64),
1762                );
1763                store.set_config_tree(
1764                    &curr_key,
1765                    &crate::serde_json::Value::Number(initial_current as f64),
1766                );
1767                Ok(RuntimeQueryResult::ok_message(
1768                    query.to_string(),
1769                    &format!(
1770                        "sequence {} created (start={}, increment={})",
1771                        q.name, q.start, q.increment
1772                    ),
1773                    "create_sequence",
1774                ))
1775            }
1776            QueryExpr::DropSequence(ref q) => {
1777                let store = self.inner.db.store();
1778                let base = format!("sequence.{}", q.name);
1779                let existed = store.get_config(&format!("{base}.start")).is_some();
1780                if !existed && !q.if_exists {
1781                    return Err(RedDBError::Internal(format!(
1782                        "sequence {} does not exist",
1783                        q.name
1784                    )));
1785                }
1786                for k in ["start", "increment", "current"] {
1787                    store.set_config_tree(&format!("{base}.{k}"), &crate::serde_json::Value::Null);
1788                }
1789                Ok(RuntimeQueryResult::ok_message(
1790                    query.to_string(),
1791                    &format!("sequence {} dropped", q.name),
1792                    "drop_sequence",
1793                ))
1794            }
1795            // Views — CREATE [MATERIALIZED] VIEW (Phase 2.1 PG parity).
1796            //
1797            // The view definition is stored in-memory on RuntimeInner (not
1798            // persisted). SELECTs that reference the view name will substitute
1799            // the stored `QueryExpr` via `resolve_view_reference` during
1800            // planning (same entry point used by table-name resolution).
1801            //
1802            // Materialized views additionally allocate a slot in
1803            // `MaterializedViewCache`; a REFRESH repopulates that slot.
1804            QueryExpr::CreateView(ref q) => {
1805                let mut views = self.inner.views.write();
1806                if views.contains_key(&q.name) && !q.or_replace {
1807                    if q.if_not_exists {
1808                        return Ok(RuntimeQueryResult::ok_message(
1809                            query.to_string(),
1810                            &format!("view {} already exists — skipped", q.name),
1811                            "create_view",
1812                        ));
1813                    }
1814                    return Err(RedDBError::Internal(format!(
1815                        "view {} already exists",
1816                        q.name
1817                    )));
1818                }
1819                views.insert(q.name.clone(), Arc::new(q.clone()));
1820                drop(views);
1821
1822                // Materialized view: register cache slot (data is empty until REFRESH).
1823                if q.materialized {
1824                    use crate::storage::cache::result::{MaterializedViewDef, RefreshPolicy};
1825                    let refresh = match q.refresh_every_ms {
1826                        Some(ms) => RefreshPolicy::Periodic(std::time::Duration::from_millis(ms)),
1827                        None => RefreshPolicy::Manual,
1828                    };
1829                    let dependencies = collect_table_refs(&q.query);
1830                    let def = MaterializedViewDef {
1831                        name: q.name.clone(),
1832                        query: format!("<parsed view {}>", q.name),
1833                        dependencies: dependencies.clone(),
1834                        refresh,
1835                        retention_duration_ms: q.retention_duration_ms,
1836                    };
1837                    self.inner.materialized_views.write().register(def);
1838
1839                    // Issue #593 slice 9a — persist the descriptor to
1840                    // the system catalog so the definition survives a
1841                    // restart. Upsert semantics (delete-then-insert by
1842                    // name) keep the catalog free of duplicate rows
1843                    // across `CREATE OR REPLACE` churn.
1844                    let descriptor =
1845                        crate::runtime::continuous_materialized_view::MaterializedViewDescriptor {
1846                            name: q.name.clone(),
1847                            source_sql: query.to_string(),
1848                            source_collections: dependencies,
1849                            refresh_every_ms: q.refresh_every_ms,
1850                            retention_duration_ms: q.retention_duration_ms,
1851                        };
1852                    let store = self.inner.db.store();
1853                    crate::runtime::continuous_materialized_view::persist_descriptor(
1854                        store.as_ref(),
1855                        &descriptor,
1856                    )?;
1857
1858                    // Issue #594 slice 9b — provision a Table-shaped
1859                    // backing collection named after the view. The
1860                    // rewriter skips materialized views (see
1861                    // `rewrite_view_refs_inner`) so `SELECT FROM v`
1862                    // resolves to this collection directly. Empty
1863                    // until REFRESH wires through it in 9c.
1864                    self.ensure_materialized_view_backing(&q.name)?;
1865                }
1866                // Plan cache may have cached a plan that didn't know about this
1867                // view — invalidate so future references pick up the new binding.
1868                // Result cache gets flushed too: OR REPLACE must not serve a
1869                // prior execution of the obsolete body.
1870                self.invalidate_plan_cache();
1871                self.invalidate_result_cache();
1872
1873                Ok(RuntimeQueryResult::ok_message(
1874                    query.to_string(),
1875                    &format!(
1876                        "{}view {} created",
1877                        if q.materialized { "materialized " } else { "" },
1878                        q.name
1879                    ),
1880                    "create_view",
1881                ))
1882            }
1883            QueryExpr::DropView(ref q) => {
1884                let mut views = self.inner.views.write();
1885                let removed = views.remove(&q.name);
1886                let existed = removed.is_some();
1887                let removed_materialized =
1888                    removed.as_ref().map(|v| v.materialized).unwrap_or(false);
1889                drop(views);
1890                if q.materialized || existed {
1891                    // Try the materialised cache too — silent if absent.
1892                    self.inner.materialized_views.write().remove(&q.name);
1893                    // Issue #593 slice 9a — remove any persisted
1894                    // catalog row. Idempotent: a no-op when the view
1895                    // was never materialized (no row was ever written).
1896                    let store = self.inner.db.store();
1897                    crate::runtime::continuous_materialized_view::remove_by_name(
1898                        store.as_ref(),
1899                        &q.name,
1900                    )?;
1901                }
1902                // Issue #594 slice 9b — drop the backing collection
1903                // that was provisioned at CREATE time. Only mat views
1904                // ever had one; regular views never did.
1905                if removed_materialized || q.materialized {
1906                    self.drop_materialized_view_backing(&q.name)?;
1907                }
1908                // Drop any plan / result cache entries that baked the
1909                // view body into their QueryExpr.
1910                self.invalidate_plan_cache();
1911                self.invalidate_result_cache();
1912                if !existed && !q.if_exists {
1913                    return Err(RedDBError::Internal(format!(
1914                        "view {} does not exist",
1915                        q.name
1916                    )));
1917                }
1918                self.invalidate_plan_cache();
1919                Ok(RuntimeQueryResult::ok_message(
1920                    query.to_string(),
1921                    &format!("view {} dropped", q.name),
1922                    "drop_view",
1923                ))
1924            }
1925            QueryExpr::RefreshMaterializedView(ref q) => {
1926                // Look up the view definition, execute its underlying query,
1927                // and stash the serialized result in the materialised cache.
1928                let view = {
1929                    let views = self.inner.views.read();
1930                    views.get(&q.name).cloned()
1931                };
1932                let view = match view {
1933                    Some(v) => v,
1934                    None => {
1935                        return Err(RedDBError::Internal(format!(
1936                            "view {} does not exist",
1937                            q.name
1938                        )))
1939                    }
1940                };
1941                if !view.materialized {
1942                    return Err(RedDBError::Internal(format!(
1943                        "view {} is not materialized — REFRESH requires \
1944                         CREATE MATERIALIZED VIEW",
1945                        q.name
1946                    )));
1947                }
1948                // Execute the underlying query fresh.
1949                let started = std::time::Instant::now();
1950                let now_ms = std::time::SystemTime::now()
1951                    .duration_since(std::time::UNIX_EPOCH)
1952                    .map(|d| d.as_millis() as u64)
1953                    .unwrap_or(0);
1954                match self.execute_query_expr((*view.query).clone()) {
1955                    Ok(inner_result) => {
1956                        // Issue #595 slice 9c — atomically replace the
1957                        // backing collection's contents under a single
1958                        // WAL group. Concurrent SELECT from the view
1959                        // sees either the prior or new contents, never
1960                        // partial. A crash before the WAL commit lands
1961                        // leaves the prior contents intact on recovery.
1962                        let entities =
1963                            view_records_to_entities(&q.name, &inner_result.result.records);
1964                        let row_count = entities.len() as u64;
1965                        let store = self.inner.db.store();
1966                        let serialized_records = match store.refresh_collection(&q.name, entities) {
1967                            Ok(records) => records,
1968                            Err(err) => {
1969                                let duration_ms = started.elapsed().as_millis() as u64;
1970                                let msg = err.to_string();
1971                                self.inner
1972                                    .materialized_views
1973                                    .write()
1974                                    .record_refresh_failure(
1975                                        &q.name,
1976                                        msg.clone(),
1977                                        duration_ms,
1978                                        now_ms,
1979                                    );
1980                                return Err(RedDBError::Internal(format!(
1981                                    "REFRESH MATERIALIZED VIEW {}: {msg}",
1982                                    q.name
1983                                )));
1984                            }
1985                        };
1986
1987                        // Issue #596 slice 9d — emit a Refresh
1988                        // ChangeRecord into the logical-WAL spool so
1989                        // replicas deterministically replay the same
1990                        // backing-collection contents via
1991                        // `LogicalChangeApplier::apply_record`.
1992                        if self.inner.db.replication.is_some() {
1993                            let lsn = self.inner.cdc.emit(
1994                                crate::replication::cdc::ChangeOperation::Refresh,
1995                                &q.name,
1996                                0,
1997                                "refresh",
1998                            );
1999                            self.invalidate_result_cache_for_table(&q.name);
2000                            let timestamp = std::time::SystemTime::now()
2001                                .duration_since(std::time::UNIX_EPOCH)
2002                                .unwrap_or_default()
2003                                .as_millis() as u64;
2004                            let record = ChangeRecord::for_refresh(
2005                                lsn,
2006                                timestamp,
2007                                q.name.clone(),
2008                                serialized_records,
2009                            )
2010                            .with_term(self.current_replication_term());
2011                            self.stage_or_publish_logical_record(record)?;
2012                        }
2013
2014                        let duration_ms = started.elapsed().as_millis() as u64;
2015                        let serialized = format!("{:?}", inner_result.result);
2016                        self.inner
2017                            .materialized_views
2018                            .write()
2019                            .record_refresh_success(
2020                                &q.name,
2021                                serialized.into_bytes(),
2022                                row_count,
2023                                duration_ms,
2024                                now_ms,
2025                            );
2026                        // SELECT FROM v now reads through the rewriter
2027                        // skip into the backing collection — drop the
2028                        // result cache so prior empty-backing reads
2029                        // don't shadow the new contents.
2030                        self.invalidate_result_cache();
2031                        Ok(RuntimeQueryResult::ok_message(
2032                            query.to_string(),
2033                            &format!("materialized view {} refreshed", q.name),
2034                            "refresh_materialized_view",
2035                        ))
2036                    }
2037                    Err(err) => {
2038                        let duration_ms = started.elapsed().as_millis() as u64;
2039                        let msg = err.to_string();
2040                        self.inner
2041                            .materialized_views
2042                            .write()
2043                            .record_refresh_failure(&q.name, msg.clone(), duration_ms, now_ms);
2044                        Err(err)
2045                    }
2046                }
2047            }
2048            // Row Level Security (Phase 2.5 PG parity).
2049            //
2050            // Policies live in an in-memory registry keyed by (table, name).
2051            // Enforcement (AND-ing the policy's USING clause into every
2052            // query's WHERE for the table) arrives in Phase 2.5.2 via the
2053            // filter compiler; this dispatch only manages the catalog.
2054            QueryExpr::CreatePolicy(ref q) => {
2055                let key = (q.table.clone(), q.name.clone());
2056                self.inner
2057                    .rls_policies
2058                    .write()
2059                    .insert(key, Arc::new(q.clone()));
2060                self.invalidate_plan_cache();
2061                self.invalidate_result_cache();
2062                // Issue #120 — surface policy names in the
2063                // schema-vocabulary so AskPipeline (#121) can resolve
2064                // a policy reference back to its table.
2065                self.schema_vocabulary_apply(
2066                    crate::runtime::schema_vocabulary::DdlEvent::CreatePolicy {
2067                        collection: q.table.clone(),
2068                        policy: q.name.clone(),
2069                    },
2070                );
2071                Ok(RuntimeQueryResult::ok_message(
2072                    query.to_string(),
2073                    &format!("policy {} on {} created", q.name, q.table),
2074                    "create_policy",
2075                ))
2076            }
2077            QueryExpr::DropPolicy(ref q) => {
2078                let removed = self
2079                    .inner
2080                    .rls_policies
2081                    .write()
2082                    .remove(&(q.table.clone(), q.name.clone()))
2083                    .is_some();
2084                if !removed && !q.if_exists {
2085                    return Err(RedDBError::Internal(format!(
2086                        "policy {} on {} does not exist",
2087                        q.name, q.table
2088                    )));
2089                }
2090                self.invalidate_plan_cache();
2091                self.invalidate_result_cache();
2092                // Issue #120 — keep the schema-vocabulary policy
2093                // entry in sync.
2094                self.schema_vocabulary_apply(
2095                    crate::runtime::schema_vocabulary::DdlEvent::DropPolicy {
2096                        collection: q.table.clone(),
2097                        policy: q.name.clone(),
2098                    },
2099                );
2100                Ok(RuntimeQueryResult::ok_message(
2101                    query.to_string(),
2102                    &format!("policy {} on {} dropped", q.name, q.table),
2103                    "drop_policy",
2104                ))
2105            }
2106            // Foreign Data Wrappers (Phase 3.2 PG parity).
2107            //
2108            // CREATE SERVER / CREATE FOREIGN TABLE register into the shared
2109            // `ForeignTableRegistry`. The read path consults that registry
2110            // before dispatching a SELECT — when the table name matches a
2111            // registered foreign table, we forward the scan to the wrapper
2112            // and skip the normal collection lookup.
2113            //
2114            // Phase 3.2 is in-memory only; persistence across restarts is a
2115            // 3.2.2 follow-up that mirrors the view registry pattern.
2116            QueryExpr::CreateServer(ref q) => {
2117                use crate::storage::fdw::FdwOptions;
2118                let registry = Arc::clone(&self.inner.foreign_tables);
2119                if registry.server(&q.name).is_some() {
2120                    if q.if_not_exists {
2121                        return Ok(RuntimeQueryResult::ok_message(
2122                            query.to_string(),
2123                            &format!("server {} already exists — skipped", q.name),
2124                            "create_server",
2125                        ));
2126                    }
2127                    return Err(RedDBError::Internal(format!(
2128                        "server {} already exists",
2129                        q.name
2130                    )));
2131                }
2132                let mut opts = FdwOptions::new();
2133                for (k, v) in &q.options {
2134                    opts.values.insert(k.clone(), v.clone());
2135                }
2136                registry
2137                    .create_server(&q.name, &q.wrapper, opts)
2138                    .map_err(|e| RedDBError::Internal(e.to_string()))?;
2139                Ok(RuntimeQueryResult::ok_message(
2140                    query.to_string(),
2141                    &format!("server {} created (wrapper {})", q.name, q.wrapper),
2142                    "create_server",
2143                ))
2144            }
2145            QueryExpr::DropServer(ref q) => {
2146                let existed = self.inner.foreign_tables.drop_server(&q.name);
2147                if !existed && !q.if_exists {
2148                    return Err(RedDBError::Internal(format!(
2149                        "server {} does not exist",
2150                        q.name
2151                    )));
2152                }
2153                Ok(RuntimeQueryResult::ok_message(
2154                    query.to_string(),
2155                    &format!(
2156                        "server {} dropped{}",
2157                        q.name,
2158                        if q.cascade { " (cascade)" } else { "" }
2159                    ),
2160                    "drop_server",
2161                ))
2162            }
2163            QueryExpr::CreateForeignTable(ref q) => {
2164                use crate::storage::fdw::{FdwOptions, ForeignColumn, ForeignTable};
2165                let registry = Arc::clone(&self.inner.foreign_tables);
2166                if registry.foreign_table(&q.name).is_some() {
2167                    if q.if_not_exists {
2168                        return Ok(RuntimeQueryResult::ok_message(
2169                            query.to_string(),
2170                            &format!("foreign table {} already exists — skipped", q.name),
2171                            "create_foreign_table",
2172                        ));
2173                    }
2174                    return Err(RedDBError::Internal(format!(
2175                        "foreign table {} already exists",
2176                        q.name
2177                    )));
2178                }
2179                let mut opts = FdwOptions::new();
2180                for (k, v) in &q.options {
2181                    opts.values.insert(k.clone(), v.clone());
2182                }
2183                let columns: Vec<ForeignColumn> = q
2184                    .columns
2185                    .iter()
2186                    .map(|c| ForeignColumn {
2187                        name: c.name.clone(),
2188                        data_type: c.data_type.clone(),
2189                        not_null: c.not_null,
2190                    })
2191                    .collect();
2192                registry
2193                    .create_foreign_table(ForeignTable {
2194                        name: q.name.clone(),
2195                        server_name: q.server.clone(),
2196                        columns,
2197                        options: opts,
2198                    })
2199                    .map_err(|e| RedDBError::Internal(e.to_string()))?;
2200                self.invalidate_plan_cache();
2201                Ok(RuntimeQueryResult::ok_message(
2202                    query.to_string(),
2203                    &format!("foreign table {} created (server {})", q.name, q.server),
2204                    "create_foreign_table",
2205                ))
2206            }
2207            QueryExpr::DropForeignTable(ref q) => {
2208                let existed = self.inner.foreign_tables.drop_foreign_table(&q.name);
2209                if !existed && !q.if_exists {
2210                    return Err(RedDBError::Internal(format!(
2211                        "foreign table {} does not exist",
2212                        q.name
2213                    )));
2214                }
2215                self.invalidate_plan_cache();
2216                Ok(RuntimeQueryResult::ok_message(
2217                    query.to_string(),
2218                    &format!("foreign table {} dropped", q.name),
2219                    "drop_foreign_table",
2220                ))
2221            }
2222            // COPY table FROM 'path' (Phase 1.5 PG parity).
2223            //
2224            // Stream CSV rows through the shared `CsvImporter`. The collection
2225            // is auto-created on first insert (via `insert_auto`-style path);
2226            // VACUUM/ANALYZE afterwards is up to the caller.
2227            QueryExpr::CopyFrom(ref q) => {
2228                use crate::storage::import::{CsvConfig, CsvImporter};
2229                self.check_copy_from_privilege(&q.table)?;
2230                confine_copy_from_path(&q.path)?;
2231                let store = self.inner.db.store();
2232                let cfg = CsvConfig {
2233                    collection: q.table.clone(),
2234                    has_header: q.has_header,
2235                    delimiter: q.delimiter.map(|c| c as u8).unwrap_or(b','),
2236                    ..CsvConfig::default()
2237                };
2238                let importer = CsvImporter::new(cfg);
2239                let stats = importer
2240                    .import_file(&q.path, store.as_ref())
2241                    .map_err(|e| RedDBError::Internal(format!("COPY failed: {e}")))?;
2242                // Tables are written → invalidate cached plans / result cache.
2243                self.note_table_write(&q.table);
2244                Ok(RuntimeQueryResult::ok_message(
2245                    query.to_string(),
2246                    &format!(
2247                        "COPY imported {} rows into {} ({} errors skipped, {}ms)",
2248                        stats.records_imported, q.table, stats.errors_skipped, stats.duration_ms
2249                    ),
2250                    "copy_from",
2251                ))
2252            }
2253            // Maintenance commands (Phase 1.2 PG parity).
2254            //
2255            // - VACUUM [FULL] [table]: refreshes planner stats for the target
2256            //   collection(s) and — when FULL — triggers a full pager persist
2257            //   (flushes dirty pages + fsync). Also invalidates the result cache
2258            //   so subsequent reads re-execute against the freshly compacted
2259            //   storage. RedDB's segment/btree GC runs continuously via the
2260            //   background lifecycle; explicit space reclamation for sealed
2261            //   segments arrives with Phase 2.3 (MVCC + dead-tuple reclamation).
2262            // - ANALYZE [table]: reruns `analyze_collection` +
2263            //   `persist_table_stats` via `refresh_table_planner_stats` so the
2264            //   planner has fresh histograms, distinct estimates, null counts.
2265            //
2266            // Both commands accept an optional target; omitting the target
2267            // iterates every collection in the store.
2268            QueryExpr::MaintenanceCommand(ref cmd) => {
2269                use reddb_rql::ast::MaintenanceCommand as Mc;
2270                let store = self.inner.db.store();
2271                let (kind, msg) = match cmd {
2272                    Mc::Analyze { target } => {
2273                        let targets: Vec<String> = match target {
2274                            Some(t) => vec![t.clone()],
2275                            None => store.list_collections(),
2276                        };
2277                        for t in &targets {
2278                            self.refresh_table_planner_stats(t);
2279                        }
2280                        (
2281                            "analyze",
2282                            format!("ANALYZE refreshed stats for {} table(s)", targets.len()),
2283                        )
2284                    }
2285                    Mc::Vacuum { target, full } => {
2286                        let targets: Vec<String> = match target {
2287                            Some(t) => vec![t.clone()],
2288                            None => store.list_collections(),
2289                        };
2290                        let cutoff_xid = self.mvcc_vacuum_cutoff_xid();
2291                        let mut vacuum_stats =
2292                            crate::storage::unified::store::MvccVacuumStats::default();
2293                        for t in &targets {
2294                            let stats = store.vacuum_mvcc_history(t, cutoff_xid).map_err(|e| {
2295                                RedDBError::Internal(format!(
2296                                    "VACUUM MVCC history failed for {t}: {e}"
2297                                ))
2298                            })?;
2299                            if stats.reclaimed_versions > 0 {
2300                                self.rebuild_runtime_indexes_for_table(t)?;
2301                            }
2302                            vacuum_stats.add(&stats);
2303                        }
2304                        // The cutoff protects snapshots, not physical references.
2305                        // Aborted inserts can remain in this or another collection;
2306                        // forgetting their outcome would make them visible again.
2307                        // Stats refresh covers every target (same as ANALYZE).
2308                        for t in &targets {
2309                            self.refresh_table_planner_stats(t);
2310                        }
2311                        // FULL forces a pager persist (dirty-page flush + fsync).
2312                        // Regular VACUUM relies on the background writer / segment
2313                        // lifecycle so the command is non-blocking.
2314                        let persisted = if *full {
2315                            match store.persist() {
2316                                Ok(()) => true,
2317                                Err(e) => {
2318                                    return Err(RedDBError::Internal(format!(
2319                                        "VACUUM FULL persist failed: {e:?}"
2320                                    )));
2321                                }
2322                            }
2323                        } else {
2324                            false
2325                        };
2326                        // Result cache depended on pre-vacuum state.
2327                        self.invalidate_result_cache();
2328                        (
2329                            "vacuum",
2330                            format!(
2331                                "VACUUM{} processed {} table(s): scanned_versions={}, retained_versions={}, reclaimed_versions={}, retained_history_versions={}, reclaimed_history_versions={}, retained_tombstones={}, reclaimed_tombstones={}{}",
2332                                if *full { " FULL" } else { "" },
2333                                targets.len(),
2334                                vacuum_stats.scanned_versions,
2335                                vacuum_stats.retained_versions,
2336                                vacuum_stats.reclaimed_versions,
2337                                vacuum_stats.retained_history_versions,
2338                                vacuum_stats.reclaimed_history_versions,
2339                                vacuum_stats.retained_tombstones,
2340                                vacuum_stats.reclaimed_tombstones,
2341                                if persisted {
2342                                    " (pages flushed to disk)"
2343                                } else {
2344                                    ""
2345                                }
2346                            ),
2347                        )
2348                    }
2349                };
2350                Ok(RuntimeQueryResult::ok_message(
2351                    query.to_string(),
2352                    &msg,
2353                    kind,
2354                ))
2355            }
2356            // GRANT / REVOKE / ALTER USER (RBAC milestone).
2357            //
2358            // These hit the AuthStore directly. The statement frame /
2359            // privilege gate has already decided whether the caller may
2360            // even run the statement; here we just translate the AST into
2361            // AuthStore calls.
2362            QueryExpr::Grant(ref g) => self.execute_grant_statement(query, g),
2363            QueryExpr::Revoke(ref r) => self.execute_revoke_statement(query, r),
2364            QueryExpr::AlterUser(ref a) => self.execute_alter_user_statement(query, a),
2365            QueryExpr::CreateUser(ref u) => self.execute_create_user_statement(query, u),
2366            QueryExpr::CreateIamPolicy { ref id, ref json } => {
2367                self.execute_create_iam_policy(query, id, json)
2368            }
2369            QueryExpr::DropIamPolicy { ref id } => self.execute_drop_iam_policy(query, id),
2370            QueryExpr::AttachPolicy {
2371                ref policy_id,
2372                ref principal,
2373            } => self.execute_attach_policy(query, policy_id, principal),
2374            QueryExpr::DetachPolicy {
2375                ref policy_id,
2376                ref principal,
2377            } => self.execute_detach_policy(query, policy_id, principal),
2378            QueryExpr::ShowPolicies { ref filter } => {
2379                self.execute_show_policies(query, filter.as_ref())
2380            }
2381            QueryExpr::ShowEffectivePermissions {
2382                ref user,
2383                ref resource,
2384            } => self.execute_show_effective_permissions(query, user, resource.as_ref()),
2385            QueryExpr::SimulatePolicy {
2386                ref user,
2387                ref action,
2388                ref resource,
2389            } => self.execute_simulate_policy(query, user, action, resource),
2390            QueryExpr::LintPolicy { ref source } => self.execute_lint_policy(query, source),
2391            QueryExpr::MigratePolicyMode {
2392                ref target,
2393                dry_run,
2394            } => self.execute_migrate_policy_mode(query, target, dry_run),
2395            QueryExpr::CreateMigration(ref q) => self.execute_create_migration(query, q),
2396            QueryExpr::ApplyMigration(ref q) => self.execute_apply_migration(query, q),
2397            QueryExpr::RollbackMigration(ref q) => self.execute_rollback_migration(query, q),
2398            QueryExpr::ExplainMigration(ref q) => self.execute_explain_migration(query, q),
2399            _ => Err(RedDBError::Query(
2400                "unsupported command in runtime dispatcher".to_string(),
2401            )),
2402        };
2403
2404        if _query_budget.is_some() {
2405            super::query_budget::charge(0)?;
2406        }
2407        if !control_event_specs.is_empty() {
2408            let (outcome, reason) = match &query_result {
2409                Ok(_) => (crate::runtime::control_events::Outcome::Allowed, None),
2410                Err(err) => (control_event_outcome_for_error(err), Some(err.to_string())),
2411            };
2412            for spec in &control_event_specs {
2413                self.emit_control_event(
2414                    spec.kind,
2415                    outcome,
2416                    spec.action,
2417                    spec.resource.clone(),
2418                    reason.clone(),
2419                    spec.fields.clone(),
2420                )?;
2421            }
2422        }
2423
2424        if let (Some(plan), Ok(result)) = (&query_audit_plan, &query_result) {
2425            self.emit_query_audit(
2426                query,
2427                plan,
2428                query_audit_started.elapsed().as_millis() as u64,
2429                result,
2430            );
2431        }
2432
2433        // Cache SELECT results for 30s.
2434        // Skip: pre-serialized JSON (large clone), and result sets > 5 rows.
2435        // Large multi-row results (range scans, filtered scans) are rarely
2436        // repeated with the same literal values so the cache hit rate is near
2437        // zero while the clone cost (100 records × ~16 fields each) is high.
2438        // Aggregations (1 row) and point lookups (1 row) still benefit.
2439        if let Ok(ref result) = query_result {
2440            frame.write_result_cache(self, result, result_cache_scopes);
2441        }
2442
2443        query_result
2444    }
2445
2446    /// Execute a pre-parsed `QueryExpr` that has no accompanying SQL text,
2447    /// bypassing SQL parsing and the plan cache.
2448    ///
2449    /// Prefer [`Self::execute_prepared_query`] or
2450    /// [`Self::execute_query_with_params`]: they carry the statement text, so
2451    /// the frame's snapshot (including `AS OF`), coarse privilege class, lock
2452    /// intent, and result-cache key all describe the statement that actually
2453    /// runs. This entry exists for the nested in-statement callers — the CTE
2454    /// prelude, materialized-view refresh, and the inline graph TVF — which
2455    /// hold an expression the outer statement already parsed.
2456    ///
2457    /// Cost: this is no longer a "zero overhead" path. It builds and installs
2458    /// a full `StatementExecutionFrame` (tenant / config / secret / KV guards
2459    /// plus an MVCC snapshot), which is what buys it the same snapshot
2460    /// isolation as textual SQL. Nested calls inherit the enclosing
2461    /// statement's snapshot rather than minting a new one, and the frame opts
2462    /// out of the result cache because there is no stable text key.
2463    ///
2464    /// Keeps secret SELECT results masked, identical to `execute_query`.
2465    pub fn execute_query_expr(&self, expr: QueryExpr) -> RedDBResult<RuntimeQueryResult> {
2466        let frame = super::statement_frame::StatementExecutionFrame::build(
2467            self,
2468            super::statement_frame::StatementIdentity::Expr(&expr),
2469        )?;
2470        let _frame_guards = Box::new(frame.install(self));
2471        // View rewrite (Phase 2.1): substitute any `QueryExpr::Table(tq)`
2472        // whose `tq.table` matches a registered view with the view's
2473        // underlying query. Safe to call even when no views are registered.
2474        let expr = self.rewrite_view_refs(expr);
2475
2476        self.validate_model_operations_before_auth(&expr)?;
2477        // Granular RBAC privilege check. Runs before dispatch so a
2478        // denied caller never reaches storage. Fail-closed: any error
2479        // resolving the action / resource produces PermissionDenied.
2480        if let Err(err) = self.check_query_privilege(&expr) {
2481            return Err(RedDBError::Query(format!("permission denied: {err}")));
2482        }
2483
2484        frame.release_snapshot_for_maintenance(&expr);
2485        let statement = query_expr_name(&expr);
2486        let mode = detect_mode(statement);
2487        let query_str = statement;
2488
2489        self.dispatch_expr(expr, query_str, mode)
2490    }
2491
2492    pub(super) fn validate_model_operations_before_auth(
2493        &self,
2494        expr: &QueryExpr,
2495    ) -> RedDBResult<()> {
2496        self.validate_vault_table_access(expr)?;
2497        use crate::catalog::CollectionModel;
2498        use crate::runtime::ddl::polymorphic_resolver;
2499        use reddb_rql::ast::KvCommand;
2500
2501        let system_schema_target = match expr {
2502            QueryExpr::DropTable(q) => Some(q.name.as_str()),
2503            QueryExpr::DropGraph(q) => Some(q.name.as_str()),
2504            QueryExpr::DropVector(q) => Some(q.name.as_str()),
2505            QueryExpr::DropDocument(q) => Some(q.name.as_str()),
2506            QueryExpr::DropKv(q) => Some(q.name.as_str()),
2507            QueryExpr::DropCollection(q) => Some(q.name.as_str()),
2508            QueryExpr::Truncate(q) => Some(q.name.as_str()),
2509            _ => None,
2510        };
2511        if system_schema_target.is_some_and(crate::runtime::impl_ddl::is_system_schema_name) {
2512            return Err(RedDBError::Query("system schema is read-only".to_string()));
2513        }
2514
2515        let expected = match expr {
2516            QueryExpr::DropTable(q) => Some((q.name.as_str(), CollectionModel::Table)),
2517            QueryExpr::DropGraph(q) => Some((q.name.as_str(), CollectionModel::Graph)),
2518            QueryExpr::DropVector(q) => Some((q.name.as_str(), CollectionModel::Vector)),
2519            QueryExpr::DropDocument(q) => Some((q.name.as_str(), CollectionModel::Document)),
2520            QueryExpr::DropKv(q) => Some((q.name.as_str(), q.model)),
2521            QueryExpr::DropCollection(q) => q.model.map(|model| (q.name.as_str(), model)),
2522            QueryExpr::Truncate(q) => q.model.map(|model| (q.name.as_str(), model)),
2523            QueryExpr::KvCommand(cmd) => {
2524                let (collection, model) = match cmd {
2525                    KvCommand::Put {
2526                        collection, model, ..
2527                    }
2528                    | KvCommand::Get {
2529                        collection, model, ..
2530                    }
2531                    | KvCommand::Incr {
2532                        collection, model, ..
2533                    }
2534                    | KvCommand::Cas {
2535                        collection, model, ..
2536                    }
2537                    | KvCommand::List {
2538                        collection, model, ..
2539                    }
2540                    | KvCommand::Delete {
2541                        collection, model, ..
2542                    } => (collection.as_str(), *model),
2543                    KvCommand::Rotate { collection, .. }
2544                    | KvCommand::History { collection, .. }
2545                    | KvCommand::Purge { collection, .. } => {
2546                        (collection.as_str(), CollectionModel::Vault)
2547                    }
2548                    KvCommand::InvalidateTags { collection, .. } => {
2549                        (collection.as_str(), CollectionModel::Kv)
2550                    }
2551                    KvCommand::Watch {
2552                        collection, model, ..
2553                    } => (collection.as_str(), *model),
2554                    KvCommand::Reveal { collection, .. } => {
2555                        (collection.as_str(), CollectionModel::Vault)
2556                    }
2557                };
2558                Some((collection, model))
2559            }
2560            QueryExpr::ConfigCommand(cmd) => {
2561                self.validate_config_command_before_auth(cmd)?;
2562                None
2563            }
2564            _ => None,
2565        };
2566
2567        let Some((name, expected_model)) = expected else {
2568            return Ok(());
2569        };
2570        // Declared models are authoritative in the catalog snapshot. Resolve
2571        // just this contract instead of observing every row in every collection
2572        // for a point command. Keep the snapshot fallback for undeclared legacy
2573        // collections and orphaned contracts, preserving their inference rules.
2574        if let Some(contract) = self.inner.db.collection_contract_arc(name) {
2575            if self.inner.db.store().get_collection(name).is_some() {
2576                return polymorphic_resolver::ensure_model_match(
2577                    expected_model,
2578                    contract.declared_model,
2579                );
2580            }
2581        }
2582        let snapshot = self.inner.db.catalog_model_snapshot();
2583        let Some(actual_model) = snapshot
2584            .collections
2585            .iter()
2586            .find(|collection| collection.name == name)
2587            .map(|collection| collection.declared_model.unwrap_or(collection.model))
2588        else {
2589            return Ok(());
2590        };
2591        polymorphic_resolver::ensure_model_match(expected_model, actual_model)
2592    }
2593
2594    /// Walk a `QueryExpr` and replace `QueryExpr::Table(tq)` nodes whose
2595    /// `tq.table` matches a registered view name with the view's stored
2596    /// body. Recurses through joins so `SELECT ... FROM t JOIN myview ...`
2597    /// resolves correctly. Pure operation — no side effects.
2598    pub(super) fn rewrite_view_refs(&self, expr: QueryExpr) -> QueryExpr {
2599        // Fast path: no views registered → return original expression.
2600        if self.inner.views.read().is_empty() {
2601            return expr;
2602        }
2603        self.rewrite_view_refs_inner(expr)
2604    }
2605
2606    fn rewrite_view_refs_inner(&self, expr: QueryExpr) -> QueryExpr {
2607        use reddb_rql::ast::{Filter, TableSource};
2608        match expr {
2609            QueryExpr::Table(mut tq) => {
2610                // 1. If the TableSource is a subquery, recurse into it so
2611                //    `SELECT ... FROM (SELECT ... FROM myview) t` expands.
2612                //    The legacy `table` field (set to a synthetic
2613                //    "__subq_NNNN" sentinel) stays as-is so callers that
2614                //    read it keep compiling.
2615                if let Some(TableSource::Subquery(body)) = tq.source.take() {
2616                    tq.source = Some(TableSource::Subquery(Box::new(
2617                        self.rewrite_view_refs_inner(*body),
2618                    )));
2619                    return QueryExpr::Table(tq);
2620                }
2621
2622                // 2. Restore the source field (took it above for match).
2623                // When the source was `None` or `TableSource::Name(_)`, the
2624                // real lookup key is `tq.table` — check the view registry.
2625                let maybe_view = {
2626                    let views = self.inner.views.read();
2627                    views.get(&tq.table).cloned()
2628                };
2629                let Some(view) = maybe_view else {
2630                    return QueryExpr::Table(tq);
2631                };
2632
2633                // Issue #594 slice 9b — materialized views are read
2634                // from their backing collection, not by substituting
2635                // the body. Returning the TableQuery as-is lets the
2636                // normal table-read path resolve `SELECT FROM v`
2637                // against the collection provisioned at CREATE time.
2638                if view.materialized {
2639                    return QueryExpr::Table(tq);
2640                }
2641
2642                // Recurse into the view body — views may reference other
2643                // views. The recursion yields the final QueryExpr we need
2644                // to merge the outer's filter / limit / offset into.
2645                let inner_expr = self.rewrite_view_refs_inner((*view.query).clone());
2646
2647                // Phase 5: when the body is a Table we merge the outer
2648                // TableQuery's WHERE / LIMIT / OFFSET into it so stacked
2649                // views filter recursively. Non-table bodies (Search,
2650                // Ask, Vector, Graph, Hybrid) can't meaningfully combine
2651                // with an outer Table query today — return the body
2652                // verbatim; outer predicates are lost. Full projection
2653                // merge lands in Phase 5.2.
2654                match inner_expr {
2655                    QueryExpr::Table(mut inner_tq) => {
2656                        if let Some(outer_filter) = tq.filter.take() {
2657                            inner_tq.filter = Some(match inner_tq.filter.take() {
2658                                Some(existing) => {
2659                                    Filter::And(Box::new(existing), Box::new(outer_filter))
2660                                }
2661                                None => outer_filter,
2662                            });
2663                            // Keep the `Expr` form in lock-step with the
2664                            // merged `Filter`. The executor prefers
2665                            // `where_expr` and nulls `filter` when it is
2666                            // present (see `execute_query_inner`), so a
2667                            // stacked view whose outer predicate was only
2668                            // merged into `filter` would silently drop that
2669                            // predicate at eval time (#635).
2670                            inner_tq.where_expr = inner_tq
2671                                .filter
2672                                .as_ref()
2673                                .map(reddb_rql::sql_lowering::filter_to_expr);
2674                        }
2675                        if let Some(outer_limit) = tq.limit {
2676                            inner_tq.limit = Some(match inner_tq.limit {
2677                                Some(existing) => existing.min(outer_limit),
2678                                None => outer_limit,
2679                            });
2680                        }
2681                        if let Some(outer_offset) = tq.offset {
2682                            inner_tq.offset = Some(match inner_tq.offset {
2683                                Some(existing) => existing + outer_offset,
2684                                None => outer_offset,
2685                            });
2686                        }
2687                        QueryExpr::Table(inner_tq)
2688                    }
2689                    other => other,
2690                }
2691            }
2692            QueryExpr::Join(mut jq) => {
2693                jq.left = Box::new(self.rewrite_view_refs_inner(*jq.left));
2694                jq.right = Box::new(self.rewrite_view_refs_inner(*jq.right));
2695                QueryExpr::Join(jq)
2696            }
2697            // Other variants don't carry nested QueryExpr that can reference
2698            // a view by table name. Return as-is.
2699            other => other,
2700        }
2701    }
2702
2703    fn resolve_table_expr_subqueries(
2704        &self,
2705        mut table: TableQuery,
2706        frame: &dyn super::statement_frame::ReadFrame,
2707    ) -> RedDBResult<TableQuery> {
2708        // Only a `Subquery` source needs recursive resolution. `.take()`
2709        // would otherwise drop a `Name` / `Function` source on the floor
2710        // (the `if let` skips the body but the take already cleared it),
2711        // which silently broke `SELECT * FROM components(g)` — the TVF
2712        // dispatch downstream keys off `TableSource::Function` and never
2713        // fired. Restore any non-subquery source unchanged (issue #795).
2714        match table.source.take() {
2715            Some(TableSource::Subquery(inner)) => {
2716                let inner = self.resolve_select_expr_subqueries(*inner, frame)?;
2717                table.source = Some(TableSource::Subquery(Box::new(inner)));
2718            }
2719            other => table.source = other,
2720        }
2721
2722        let outer_scopes = relation_scopes_for_query(&QueryExpr::Table(table.clone()));
2723        for item in &mut table.select_items {
2724            if let reddb_rql::ast::SelectItem::Expr { expr, .. } = item {
2725                *expr = self.resolve_expr_subqueries(expr.clone(), &outer_scopes, frame)?;
2726            }
2727        }
2728        if let Some(where_expr) = table.where_expr.take() {
2729            table.where_expr =
2730                Some(self.resolve_expr_subqueries(where_expr, &outer_scopes, frame)?);
2731            table.filter = None;
2732        }
2733        if let Some(having_expr) = table.having_expr.take() {
2734            table.having_expr =
2735                Some(self.resolve_expr_subqueries(having_expr, &outer_scopes, frame)?);
2736            table.having = None;
2737        }
2738        for expr in &mut table.group_by_exprs {
2739            *expr = self.resolve_expr_subqueries(expr.clone(), &outer_scopes, frame)?;
2740        }
2741        for clause in &mut table.order_by {
2742            if let Some(expr) = clause.expr.take() {
2743                clause.expr = Some(self.resolve_expr_subqueries(expr, &outer_scopes, frame)?);
2744            }
2745        }
2746        Ok(table)
2747    }
2748
2749    fn resolve_select_expr_subqueries(
2750        &self,
2751        expr: QueryExpr,
2752        frame: &dyn super::statement_frame::ReadFrame,
2753    ) -> RedDBResult<QueryExpr> {
2754        match expr {
2755            QueryExpr::Table(table) => self
2756                .resolve_table_expr_subqueries(table, frame)
2757                .map(QueryExpr::Table),
2758            QueryExpr::Join(mut join) => {
2759                join.left = Box::new(self.resolve_select_expr_subqueries(*join.left, frame)?);
2760                join.right = Box::new(self.resolve_select_expr_subqueries(*join.right, frame)?);
2761                Ok(QueryExpr::Join(join))
2762            }
2763            other => Ok(other),
2764        }
2765    }
2766
2767    fn resolve_expr_subqueries(
2768        &self,
2769        expr: reddb_rql::ast::Expr,
2770        outer_scopes: &[String],
2771        frame: &dyn super::statement_frame::ReadFrame,
2772    ) -> RedDBResult<reddb_rql::ast::Expr> {
2773        use reddb_rql::ast::Expr;
2774
2775        match expr {
2776            Expr::Subquery { query, span } => {
2777                let values = self.execute_expr_subquery_values(query, outer_scopes, frame)?;
2778                if values.len() > 1 {
2779                    return Err(RedDBError::Query(
2780                        "scalar subquery returned more than one row".to_string(),
2781                    ));
2782                }
2783                Ok(Expr::Literal {
2784                    value: values.into_iter().next().unwrap_or(Value::Null),
2785                    span,
2786                })
2787            }
2788            Expr::BinaryOp { op, lhs, rhs, span } => Ok(Expr::BinaryOp {
2789                op,
2790                lhs: Box::new(self.resolve_expr_subqueries(*lhs, outer_scopes, frame)?),
2791                rhs: Box::new(self.resolve_expr_subqueries(*rhs, outer_scopes, frame)?),
2792                span,
2793            }),
2794            Expr::UnaryOp { op, operand, span } => Ok(Expr::UnaryOp {
2795                op,
2796                operand: Box::new(self.resolve_expr_subqueries(*operand, outer_scopes, frame)?),
2797                span,
2798            }),
2799            Expr::Cast {
2800                inner,
2801                target,
2802                span,
2803            } => Ok(Expr::Cast {
2804                inner: Box::new(self.resolve_expr_subqueries(*inner, outer_scopes, frame)?),
2805                target,
2806                span,
2807            }),
2808            Expr::FunctionCall { name, args, span } => {
2809                let args = args
2810                    .into_iter()
2811                    .map(|arg| self.resolve_expr_subqueries(arg, outer_scopes, frame))
2812                    .collect::<RedDBResult<Vec<_>>>()?;
2813                Ok(Expr::FunctionCall { name, args, span })
2814            }
2815            Expr::WindowFunctionCall {
2816                name,
2817                args,
2818                mut window,
2819                span,
2820            } => {
2821                let args = args
2822                    .into_iter()
2823                    .map(|arg| self.resolve_expr_subqueries(arg, outer_scopes, frame))
2824                    .collect::<RedDBResult<Vec<_>>>()?;
2825                for expr in &mut window.partition_by {
2826                    *expr = self.resolve_expr_subqueries(expr.clone(), outer_scopes, frame)?;
2827                }
2828                for clause in &mut window.order_by {
2829                    clause.expr =
2830                        self.resolve_expr_subqueries(clause.expr.clone(), outer_scopes, frame)?;
2831                }
2832                Ok(Expr::WindowFunctionCall {
2833                    name,
2834                    args,
2835                    window,
2836                    span,
2837                })
2838            }
2839            Expr::Case {
2840                branches,
2841                else_,
2842                span,
2843            } => {
2844                let branches = branches
2845                    .into_iter()
2846                    .map(|(cond, value)| {
2847                        Ok((
2848                            self.resolve_expr_subqueries(cond, outer_scopes, frame)?,
2849                            self.resolve_expr_subqueries(value, outer_scopes, frame)?,
2850                        ))
2851                    })
2852                    .collect::<RedDBResult<Vec<_>>>()?;
2853                let else_ = else_
2854                    .map(|expr| self.resolve_expr_subqueries(*expr, outer_scopes, frame))
2855                    .transpose()?
2856                    .map(Box::new);
2857                Ok(Expr::Case {
2858                    branches,
2859                    else_,
2860                    span,
2861                })
2862            }
2863            Expr::IsNull {
2864                operand,
2865                negated,
2866                span,
2867            } => Ok(Expr::IsNull {
2868                operand: Box::new(self.resolve_expr_subqueries(*operand, outer_scopes, frame)?),
2869                negated,
2870                span,
2871            }),
2872            Expr::InList {
2873                target,
2874                values,
2875                negated,
2876                span,
2877            } => {
2878                let target =
2879                    Box::new(self.resolve_expr_subqueries(*target, outer_scopes, frame)?);
2880                let mut resolved = Vec::new();
2881                for value in values {
2882                    if let Expr::Subquery { query, .. } = value {
2883                        resolved.extend(
2884                            self.execute_expr_subquery_values(query, outer_scopes, frame)?
2885                                .into_iter()
2886                                .map(Expr::lit),
2887                        );
2888                    } else {
2889                        resolved.push(self.resolve_expr_subqueries(value, outer_scopes, frame)?);
2890                    }
2891                }
2892                Ok(Expr::InList {
2893                    target,
2894                    values: resolved,
2895                    negated,
2896                    span,
2897                })
2898            }
2899            Expr::Between {
2900                target,
2901                low,
2902                high,
2903                negated,
2904                span,
2905            } => Ok(Expr::Between {
2906                target: Box::new(self.resolve_expr_subqueries(*target, outer_scopes, frame)?),
2907                low: Box::new(self.resolve_expr_subqueries(*low, outer_scopes, frame)?),
2908                high: Box::new(self.resolve_expr_subqueries(*high, outer_scopes, frame)?),
2909                negated,
2910                span,
2911            }),
2912            other => Ok(other),
2913        }
2914    }
2915
2916    fn execute_expr_subquery_values(
2917        &self,
2918        subquery: reddb_rql::ast::ExprSubquery,
2919        outer_scopes: &[String],
2920        frame: &dyn super::statement_frame::ReadFrame,
2921    ) -> RedDBResult<Vec<Value>> {
2922        let query = *subquery.query;
2923        if query_references_outer_scope(&query, outer_scopes) {
2924            return Err(RedDBError::Query(
2925                "NOT_YET_SUPPORTED: correlated subqueries are not supported yet; track follow-up issue #470-correlated-subqueries".to_string(),
2926            ));
2927        }
2928        let query = self.rewrite_view_refs(query);
2929        let query = self.resolve_select_expr_subqueries(query, frame)?;
2930        let query = self.authorize_relational_select_expr(query, frame)?;
2931        let result = match query {
2932            QueryExpr::Table(table) => {
2933                execute_runtime_table_query(&self.inner.db, &table, Some(&self.inner.index_store))?
2934            }
2935            QueryExpr::Join(join) => execute_runtime_join_query(self, &join)?,
2936            other => {
2937                return Err(RedDBError::Query(format!(
2938                    "expression subquery must be a SELECT query, got {}",
2939                    query_expr_name(&other)
2940                )))
2941            }
2942        };
2943        first_column_values(result)
2944    }
2945
2946    fn execute_materialized_graph(
2947        &self,
2948        query: &str,
2949        mode: QueryMode,
2950        statement: &'static str,
2951        expr: &QueryExpr,
2952    ) -> RedDBResult<RuntimeQueryResult> {
2953        // Apply MVCC visibility + RLS gate while materialising the
2954        // graph: every node entity is screened against the source
2955        // collection's policy chain (basic and `Nodes`-targeted)
2956        // and dropped when the caller's tenant / role doesn't
2957        // admit it. Edges are pruned automatically because the
2958        // graph builder skips edges whose endpoints aren't in
2959        // `allowed_nodes`.
2960        let (graph, node_properties, edge_properties) = self.materialize_graph_with_rls()?;
2961        let result =
2962            crate::storage::query::unified::UnifiedExecutor::execute_on_with_graph_properties_checked(
2963                &graph,
2964                expr,
2965                node_properties,
2966                edge_properties,
2967                super::function_budget::graph_work_check(),
2968            )
2969            .map_err(|err| RedDBError::Query(err.to_string()))?;
2970
2971        Ok(RuntimeQueryResult {
2972            query: query.to_string(),
2973            mode,
2974            statement,
2975            engine: "materialized-graph",
2976            result,
2977            affected_rows: 0,
2978            statement_type: "select",
2979            bookmark: None,
2980            notice: None,
2981        })
2982    }
2983
2984    fn dispatch_expr(
2985        &self,
2986        expr: QueryExpr,
2987        query_str: &str,
2988        mode: QueryMode,
2989    ) -> RedDBResult<RuntimeQueryResult> {
2990        // Typed read plans only: mutation/transaction cleanup must never fail
2991        // because a read result or cooperative deadline budget was exhausted.
2992        let is_read = super::query_budget::is_materialized_read(&expr);
2993        let _budget = if is_read {
2994            Some(super::query_budget::Scope::enter(
2995                self.resource_limits(),
2996                &self.inner.query_budget_slots,
2997            )?)
2998        } else {
2999            None
3000        };
3001        let result = self.dispatch_expr_inner(expr, query_str, mode);
3002        if is_read {
3003            super::query_budget::charge(0)?;
3004        }
3005        let result = result?;
3006        super::query_budget::check_result(self.resource_limits(), &result)?;
3007        if is_read {
3008            super::query_budget::charge(0)?;
3009        }
3010        Ok(result)
3011    }
3012
3013    fn dispatch_expr_inner(
3014        &self,
3015        expr: QueryExpr,
3016        query_str: &str,
3017        mode: QueryMode,
3018    ) -> RedDBResult<RuntimeQueryResult> {
3019        let statement = query_expr_name(&expr);
3020        match expr {
3021            QueryExpr::Function(command) => self.execute_function(query_str, &command),
3022            QueryExpr::Graph(_) | QueryExpr::Path(_) => {
3023                self.execute_materialized_graph(query_str, mode, statement, &expr)
3024            }
3025            QueryExpr::Table(table) => {
3026                let scope = self.ai_scope();
3027                let table = self.resolve_table_expr_subqueries(
3028                    table,
3029                    &scope as &dyn super::statement_frame::ReadFrame,
3030                )?;
3031                // Table-valued functions (e.g. components(g)) dispatch to a
3032                // read-only executor before any catalog/virtual-table routing
3033                // (issue #795).
3034                if let Some(TableSource::Function {
3035                    name,
3036                    args,
3037                    named_args,
3038                }) = table.source.clone()
3039                {
3040                    return Ok(RuntimeQueryResult {
3041                        query: query_str.to_string(),
3042                        mode,
3043                        statement,
3044                        engine: "runtime-graph-tvf",
3045                        result: self.execute_table_function(&name, &args, &named_args)?,
3046                        affected_rows: 0,
3047                        statement_type: "select",
3048                        bookmark: None,
3049                        notice: None,
3050                    });
3051                }
3052                // Inline-graph TVF (issue #799) on the prepared-statement /
3053                // direct-expr path. Result caching is wired on the
3054                // `execute_query_inner` path; here we just compute and return.
3055                if let Some(TableSource::InlineGraphFunction {
3056                    name,
3057                    nodes,
3058                    edges,
3059                    named_args,
3060                }) = table.source.clone()
3061                {
3062                    return Ok(RuntimeQueryResult {
3063                        query: query_str.to_string(),
3064                        mode,
3065                        statement,
3066                        engine: "runtime-graph-tvf-inline",
3067                        result: self.execute_inline_graph_function(
3068                            &name,
3069                            &nodes,
3070                            &edges,
3071                            &named_args,
3072                        )?,
3073                        affected_rows: 0,
3074                        statement_type: "select",
3075                        bookmark: None,
3076                        notice: None,
3077                    });
3078                }
3079                if super::red_schema::is_virtual_table(&table.table) {
3080                    return Ok(RuntimeQueryResult {
3081                        query: query_str.to_string(),
3082                        mode,
3083                        statement,
3084                        engine: "runtime-red-schema",
3085                        result: super::red_schema::red_query(
3086                            self,
3087                            &table.table,
3088                            &table,
3089                            &scope as &dyn super::statement_frame::ReadFrame,
3090                        )?,
3091                        affected_rows: 0,
3092                        statement_type: "select",
3093                        bookmark: None,
3094                        notice: None,
3095                    });
3096                }
3097                // `<graph>.<output>` analytics virtual view (issue #800).
3098                if let Some(view_result) = self.try_resolve_analytics_view(
3099                    &table,
3100                    &scope as &dyn super::statement_frame::ReadFrame,
3101                )? {
3102                    return Ok(RuntimeQueryResult {
3103                        query: query_str.to_string(),
3104                        mode,
3105                        statement,
3106                        engine: "runtime-graph-analytics-view",
3107                        result: view_result,
3108                        affected_rows: 0,
3109                        statement_type: "select",
3110                        bookmark: None,
3111                        notice: None,
3112                    });
3113                }
3114                let Some(table_with_rls) = self.authorize_relational_table_select(
3115                    table,
3116                    &scope as &dyn super::statement_frame::ReadFrame,
3117                )?
3118                else {
3119                    return Ok(RuntimeQueryResult {
3120                        query: query_str.to_string(),
3121                        mode,
3122                        statement,
3123                        engine: "runtime-table-rls",
3124                        result: crate::storage::query::unified::UnifiedResult::empty(),
3125                        affected_rows: 0,
3126                        statement_type: "select",
3127                        bookmark: None,
3128                        notice: None,
3129                    });
3130                };
3131                Ok(RuntimeQueryResult {
3132                    query: query_str.to_string(),
3133                    mode,
3134                    statement,
3135                    engine: "runtime-table",
3136                    result: execute_runtime_table_query(
3137                        &self.inner.db,
3138                        &table_with_rls,
3139                        Some(&self.inner.index_store),
3140                    )?,
3141                    affected_rows: 0,
3142                    statement_type: "select",
3143                    bookmark: None,
3144                    notice: None,
3145                })
3146            }
3147            QueryExpr::Join(join) => {
3148                let scope = self.ai_scope();
3149                let Some(join_with_rls) = self.authorize_relational_join_select(
3150                    join,
3151                    &scope as &dyn super::statement_frame::ReadFrame,
3152                )?
3153                else {
3154                    return Ok(RuntimeQueryResult {
3155                        query: query_str.to_string(),
3156                        mode,
3157                        statement,
3158                        engine: "runtime-join-rls",
3159                        result: crate::storage::query::unified::UnifiedResult::empty(),
3160                        affected_rows: 0,
3161                        statement_type: "select",
3162                        bookmark: None,
3163                        notice: None,
3164                    });
3165                };
3166                Ok(RuntimeQueryResult {
3167                    query: query_str.to_string(),
3168                    mode,
3169                    statement,
3170                    engine: "runtime-join",
3171                    result: execute_runtime_join_query(self, &join_with_rls)?,
3172                    affected_rows: 0,
3173                    statement_type: "select",
3174                    bookmark: None,
3175                    notice: None,
3176                })
3177            }
3178            QueryExpr::Vector(vector) => Ok(RuntimeQueryResult {
3179                query: query_str.to_string(),
3180                mode,
3181                statement,
3182                engine: "runtime-vector",
3183                result: execute_runtime_vector_query(self, &vector)?,
3184                affected_rows: 0,
3185                statement_type: "select",
3186                bookmark: None,
3187                notice: None,
3188            }),
3189            QueryExpr::Hybrid(hybrid) => Ok(RuntimeQueryResult {
3190                query: query_str.to_string(),
3191                mode,
3192                statement,
3193                engine: "runtime-hybrid",
3194                result: execute_runtime_hybrid_query(self, &hybrid)?,
3195                affected_rows: 0,
3196                statement_type: "select",
3197                bookmark: None,
3198                notice: None,
3199            }),
3200            QueryExpr::Insert(ref insert) if super::red_schema::is_virtual_table(&insert.table) => {
3201                Err(RedDBError::Query(
3202                    super::red_schema::READ_ONLY_ERROR.to_string(),
3203                ))
3204            }
3205            QueryExpr::Update(ref update) if super::red_schema::is_virtual_table(&update.table) => {
3206                Err(RedDBError::Query(
3207                    super::red_schema::READ_ONLY_ERROR.to_string(),
3208                ))
3209            }
3210            QueryExpr::Delete(ref delete) if super::red_schema::is_virtual_table(&delete.table) => {
3211                Err(RedDBError::Query(
3212                    super::red_schema::READ_ONLY_ERROR.to_string(),
3213                ))
3214            }
3215            QueryExpr::Insert(ref insert) => self
3216                .with_deferred_store_wal_for_dml(self.insert_may_emit_events(insert), || {
3217                    self.execute_insert(query_str, insert)
3218                }),
3219            QueryExpr::Update(ref update) => self
3220                .with_deferred_store_wal_for_dml(self.update_may_emit_events(update), || {
3221                    self.execute_update(query_str, update)
3222                }),
3223            QueryExpr::Delete(ref delete) => self
3224                .with_deferred_store_wal_for_dml(self.delete_may_emit_events(delete), || {
3225                    self.execute_delete(query_str, delete)
3226                }),
3227            QueryExpr::QueueCommand(ref cmd) => self.with_deferred_store_wal_if_transaction(|| {
3228                self.execute_queue_command(query_str, cmd)
3229            }),
3230            QueryExpr::KvCommand(ref cmd) => self.execute_kv_command(query_str, cmd),
3231            QueryExpr::SearchCommand(ref cmd) => self.execute_search_command(query_str, cmd),
3232            QueryExpr::Ask(ref ask) => self.execute_ask(query_str, ask),
3233            _ => Err(RedDBError::Query(format!(
3234                "prepared-statement execution does not support {statement} statements"
3235            ))),
3236        }
3237    }
3238
3239    /// Dispatch a graph-collection table-valued function call in FROM
3240    /// position (e.g. `SELECT * FROM components(g)`).
3241    ///
3242    /// Validates the function name and arity here, materializes the whole
3243    /// active graph read-only, then runs the algorithm via the shared
3244    /// `dispatch_graph_algorithm` path. Never mutates the catalog or store.
3245    fn execute_table_function(
3246        &self,
3247        name: &str,
3248        args: &[String],
3249        named_args: &[(String, f64)],
3250    ) -> RedDBResult<crate::storage::query::unified::UnifiedResult> {
3251        if name.eq_ignore_ascii_case("red.diff") {
3252            return self.execute_vcs_diff_tvf(args, named_args);
3253        }
3254        if !is_graph_tvf_name(name) {
3255            return Err(RedDBError::Query(format!("unknown table function: {name}")));
3256        }
3257        // Every graph-collection TVF takes exactly one graph argument.
3258        if args.len() != 1 {
3259            return Err(RedDBError::Query(format!(
3260                "table function '{name}' takes exactly 1 graph argument, got {}",
3261                args.len()
3262            )));
3263        }
3264
3265        // Read-only materialization of the full active graph. Passing `None`
3266        // for the projection uses the full graph store. Like #795/#796, the
3267        // v0 form runs over the whole graph store regardless of the collection
3268        // argument value. Materialization never mutates any store.
3269        let (nodes, edges) = self.materialize_whole_graph_abstract()?;
3270        self.dispatch_graph_algorithm(name, nodes, edges, named_args)
3271    }
3272
3273    pub(crate) fn revive_versioned_updates_since(
3274        &self,
3275        conn_id: u64,
3276        stamper_xid: u64,
3277    ) -> RedDBResult<usize> {
3278        // Snapshot undo identities without retaining the journal while waiting
3279        // for topology. Writers take topology before recording their undo entry.
3280        let candidates = {
3281            let guard = self.inner.pending_versioned_updates.read();
3282            let Some(pending) = guard.get(&conn_id) else {
3283                return Ok(0);
3284            };
3285            pending
3286                .iter()
3287                .rev()
3288                .filter(|entry| entry.3 >= stamper_xid)
3289                .cloned()
3290                .collect::<Vec<_>>()
3291        };
3292        let mut reverted = 0usize;
3293        for candidate in candidates {
3294            let (collection, old_id, new_id, xid, previous_xmax) = &candidate;
3295            let topology_lock = self.index_store_ref().collection_topology_lock(collection);
3296            #[cfg(test)]
3297            self.index_store_ref().mutation_test_hook(
3298                collection,
3299                super::index_store::MutationTestPhase::BeforeUndoTopology,
3300            );
3301            let topology_guard = topology_lock.read();
3302            let mut guard = self.inner.pending_versioned_updates.write();
3303            let Some(pending) = guard.get_mut(&conn_id) else {
3304                continue;
3305            };
3306            // Revalidate after waiting. Remove only successfully restored entries;
3307            // an error leaves this entry and the remaining undo journal intact.
3308            let Some(position) = pending.iter().rposition(|entry| entry == &candidate) else {
3309                continue;
3310            };
3311            self.revive_versioned_update(
3312                collection,
3313                *old_id,
3314                *new_id,
3315                *xid,
3316                *previous_xmax,
3317                topology_guard,
3318            )?;
3319            pending.remove(position);
3320            reverted += 1;
3321            if pending.is_empty() {
3322                guard.remove(&conn_id);
3323            }
3324        }
3325        Ok(reverted)
3326    }
3327
3328    /// `EXPLAIN` is peeled off the statement text before parsing, so the
3329    /// planner ran on the inner statement with no privilege check at all:
3330    /// a read-only principal could plan any write and read the table's
3331    /// column list and row estimate back out of the plan. The plan needs
3332    /// exactly the privilege the statement it describes needs.
3333    fn check_explain_target_privilege(&self, inner_sql: &str) -> RedDBResult<()> {
3334        let parsed =
3335            reddb_rql::parser::parse(inner_sql).map_err(|e| RedDBError::Query(e.to_string()))?;
3336        self.check_query_privilege(&parsed.query)
3337            .map_err(|err| RedDBError::Query(format!("permission denied: {err}")))
3338    }
3339
3340    /// Wrap the planner's `RuntimeQueryExplain` as rows on a
3341    /// `RuntimeQueryResult` so callers over the SQL interface see the
3342    /// plan tree in the same shape a SELECT produces.
3343    ///
3344    /// Columns: `op`, `source`, `estimated_rows`, `estimated_cost`, `depth`.
3345    /// Nodes are walked depth-first; `depth` counts from 0 at the
3346    /// root so a text renderer can indent without re-walking.
3347    fn explain_as_rows(&self, raw_query: &str, inner_sql: &str) -> RedDBResult<RuntimeQueryResult> {
3348        self.check_explain_target_privilege(inner_sql)?;
3349        let explain = self.explain_query(inner_sql)?;
3350
3351        let mut columns = vec![
3352            "op".to_string(),
3353            "source".to_string(),
3354            "estimated_rows".to_string(),
3355            "estimated_cost".to_string(),
3356            "depth".to_string(),
3357        ];
3358
3359        let mut records: Vec<crate::storage::query::unified::UnifiedRecord> = Vec::new();
3360
3361        // Prepend `CteScan` markers when the query carried a leading
3362        // WITH clause. The CTE bodies are already inlined into the
3363        // main plan tree, but operators reading EXPLAIN need to see
3364        // which named CTEs were resolved — without this row the plan
3365        // would look indistinguishable from a hand-inlined query.
3366        for name in &explain.cte_materializations {
3367            use std::sync::Arc;
3368            let mut rec = crate::storage::query::unified::UnifiedRecord::default();
3369            rec.set_arc(Arc::from("op"), Value::text("CteScan".to_string()));
3370            rec.set_arc(Arc::from("source"), Value::text(name.clone()));
3371            rec.set_arc(Arc::from("estimated_rows"), Value::Float(0.0));
3372            rec.set_arc(Arc::from("estimated_cost"), Value::Float(0.0));
3373            rec.set_arc(Arc::from("depth"), Value::Integer(0));
3374            records.push(rec);
3375        }
3376
3377        let vector_details = explain.statement == "vector";
3378        if vector_details {
3379            columns.extend(["access_path_reason".to_string(), "index_hint".to_string()]);
3380        }
3381        walk_plan_node(&explain.logical_plan.root, 0, &mut records, vector_details);
3382
3383        let result = crate::storage::query::unified::UnifiedResult {
3384            columns,
3385            records,
3386            stats: Default::default(),
3387            pre_serialized_json: None,
3388        };
3389
3390        Ok(RuntimeQueryResult {
3391            query: raw_query.to_string(),
3392            mode: explain.mode,
3393            statement: "explain",
3394            engine: "runtime-explain",
3395            result,
3396            affected_rows: 0,
3397            statement_type: "select",
3398            bookmark: None,
3399            notice: None,
3400        })
3401    }
3402
3403    fn explain_analyze_vector_as_rows(
3404        &self,
3405        raw_query: &str,
3406        expr: QueryExpr,
3407    ) -> RedDBResult<RuntimeQueryResult> {
3408        let QueryExpr::Vector(query) = &expr else {
3409            return Err(RedDBError::Internal(
3410                "expected vector analysis target".to_string(),
3411            ));
3412        };
3413        let source = query.collection.clone();
3414        // The typed entry installs the full statement frame and privilege gate,
3415        // and cannot serve an earlier result-cache measurement as a fresh run.
3416        let execution = self.execute_query_expr(expr)?;
3417        let stats = execution.result.stats;
3418        let vector = stats.vector.as_ref().ok_or_else(|| {
3419            RedDBError::Internal("vector executor did not provide measurements".to_string())
3420        })?;
3421        let fields = vec![
3422            ("op", Value::text(vector.access_path.clone())),
3423            ("source", Value::text(source)),
3424            ("index_used", Value::Boolean(vector.index_used)),
3425            ("mode_requested", Value::text(vector.mode_requested.clone())),
3426            ("mode_executed", Value::text(vector.mode_executed.clone())),
3427            (
3428                "fallback_reason",
3429                vector
3430                    .fallback_reason
3431                    .as_ref()
3432                    .map_or(Value::Null, |reason| Value::text(reason.clone())),
3433            ),
3434            (
3435                "approximate_distance_evaluations",
3436                Value::UnsignedInteger(vector.approximate_distance_evaluations),
3437            ),
3438            (
3439                "peak_topk_entries",
3440                Value::UnsignedInteger(vector.peak_topk_entries),
3441            ),
3442            (
3443                "candidates_examined",
3444                Value::UnsignedInteger(vector.candidates_examined),
3445            ),
3446            (
3447                "metadata_rejected",
3448                Value::UnsignedInteger(vector.metadata_rejected),
3449            ),
3450            ("rls_rejected", Value::UnsignedInteger(vector.rls_rejected)),
3451            (
3452                "visibility_rejected",
3453                Value::UnsignedInteger(vector.visibility_rejected),
3454            ),
3455            (
3456                "exact_distance_evaluations",
3457                Value::UnsignedInteger(vector.exact_distance_evaluations),
3458            ),
3459            ("actual_rows", Value::UnsignedInteger(vector.rows_returned)),
3460            (
3461                "actual_ms",
3462                Value::Float(stats.exec_time_us as f64 / 1000.0),
3463            ),
3464            ("metrics_scope", Value::text("vector_pipeline")),
3465            (
3466                "operators",
3467                Value::Json(vector.operators_json().to_string().into_bytes()),
3468            ),
3469        ];
3470        let columns = fields.iter().map(|(name, _)| name.to_string()).collect();
3471        let mut record = crate::storage::query::unified::UnifiedRecord::default();
3472        for (name, value) in fields {
3473            record.set_owned(name.to_string(), value);
3474        }
3475        Ok(RuntimeQueryResult {
3476            query: raw_query.to_string(),
3477            mode: execution.mode,
3478            statement: "explain_analyze",
3479            engine: "runtime-explain-analyze",
3480            result: crate::storage::query::unified::UnifiedResult {
3481                columns,
3482                records: vec![record],
3483                stats,
3484                pre_serialized_json: None,
3485            },
3486            affected_rows: 0,
3487            statement_type: "select",
3488            bookmark: None,
3489            notice: None,
3490        })
3491    }
3492
3493    fn explain_analyze_as_rows(
3494        &self,
3495        raw_query: &str,
3496        inner_sql: &str,
3497    ) -> RedDBResult<RuntimeQueryResult> {
3498        if strip_keyword_ci(inner_sql.trim_start(), "VECTOR").is_some() {
3499            let expr = parse_multi(inner_sql).map_err(|err| RedDBError::Query(err.to_string()))?;
3500            if matches!(&expr, QueryExpr::Vector(_)) {
3501                return self.explain_analyze_vector_as_rows(raw_query, expr);
3502            }
3503        }
3504        if !starts_with_dml_keyword(inner_sql) {
3505            return Err(RedDBError::Query(
3506                "EXPLAIN ANALYZE currently supports INSERT, UPDATE, DELETE, and VECTOR SEARCH"
3507                    .to_string(),
3508            ));
3509        }
3510
3511        self.check_explain_target_privilege(inner_sql)?;
3512        let explain = self.explain_query(inner_sql)?;
3513        let conn_id = current_connection_id();
3514        if self.inner.transaction_state.in_transaction(conn_id) {
3515            return Err(RedDBError::Query(
3516                "EXPLAIN ANALYZE requires no active transaction".to_string(),
3517            ));
3518        }
3519
3520        self.execute_query_inner("BEGIN")?;
3521        let started = std::time::Instant::now();
3522        let execution = self.execute_query_inner(inner_sql);
3523        let elapsed_ms = started.elapsed().as_secs_f64() * 1000.0;
3524        let rollback = self.execute_query_inner("ROLLBACK");
3525
3526        let affected_rows = match execution {
3527            Ok(result) => result.affected_rows,
3528            Err(err) => {
3529                rollback?;
3530                return Err(err);
3531            }
3532        };
3533        rollback?;
3534
3535        let columns = vec![
3536            "op".to_string(),
3537            "source".to_string(),
3538            "estimated_rows".to_string(),
3539            "estimated_cost".to_string(),
3540            "actual_rows".to_string(),
3541            "actual_ms".to_string(),
3542            "depth".to_string(),
3543        ];
3544        let mut records = Vec::new();
3545        walk_analyze_plan_node(
3546            &explain.logical_plan.root,
3547            0,
3548            affected_rows,
3549            elapsed_ms,
3550            &mut records,
3551        );
3552
3553        let result = crate::storage::query::unified::UnifiedResult {
3554            columns,
3555            records,
3556            stats: Default::default(),
3557            pre_serialized_json: None,
3558        };
3559
3560        Ok(RuntimeQueryResult {
3561            query: raw_query.to_string(),
3562            mode: explain.mode,
3563            statement: "explain_analyze",
3564            engine: "runtime-explain-analyze",
3565            result,
3566            affected_rows: 0,
3567            statement_type: "select",
3568            bookmark: None,
3569            notice: None,
3570        })
3571    }
3572}
3573
3574fn redact_sql_for_slow_log(query: &str) -> String {
3575    let lowered = query.to_ascii_lowercase();
3576    if lowered.contains("secret") || lowered.contains("vault") {
3577        return "secret statement (SQL redacted)".to_string();
3578    }
3579    let credential_candidate = lowered.contains("password")
3580        && ((lowered.contains("create") && lowered.contains("user"))
3581            || (lowered.contains("alter") && lowered.contains("user")));
3582    if !credential_candidate {
3583        return query.to_string();
3584    }
3585    match parse_multi(query) {
3586        Ok(QueryExpr::CreateUser(stmt)) => {
3587            let target = UserId::from_parts(stmt.tenant.as_deref(), &stmt.username);
3588            format!("CREATE USER {target} PASSWORD '***' ROLE {}", stmt.role)
3589        }
3590        Ok(QueryExpr::AlterUser(stmt))
3591            if stmt
3592                .attributes
3593                .iter()
3594                .any(|attr| matches!(attr, reddb_rql::ast::AlterUserAttribute::Password(_))) =>
3595        {
3596            let target = UserId::from_parts(stmt.tenant.as_deref(), &stmt.username);
3597            format!("ALTER USER {target} PASSWORD '***'")
3598        }
3599        Ok(_) => "credential statement (SQL redacted)".to_string(),
3600        Err(_) => "credential statement (SQL redacted)".to_string(),
3601    }
3602}
3603
3604fn strip_explain_analyze_prefix(sql: &str) -> Option<&str> {
3605    let rest = strip_keyword_ci(sql.trim_start(), "EXPLAIN")?.trim_start();
3606    Some(strip_keyword_ci(rest, "ANALYZE")?.trim_start()).filter(|inner| !inner.is_empty())
3607}
3608
3609fn strip_keyword_ci<'a>(sql: &'a str, keyword: &str) -> Option<&'a str> {
3610    // `get` is None past the end or inside a multi-byte character, where
3611    // `split_at` would panic (and a release build aborts on panic).
3612    let head = sql.get(..keyword.len())?;
3613    let rest = &sql[keyword.len()..];
3614    if !head.eq_ignore_ascii_case(keyword) {
3615        return None;
3616    }
3617    if rest
3618        .chars()
3619        .next()
3620        .is_some_and(|ch| !ch.is_ascii_whitespace())
3621    {
3622        return None;
3623    }
3624    Some(rest)
3625}
3626
3627fn starts_with_dml_keyword(sql: &str) -> bool {
3628    let trimmed = sql.trim_start();
3629    let head_end = trimmed
3630        .find(|ch: char| ch.is_ascii_whitespace())
3631        .unwrap_or(trimmed.len());
3632    let head = &trimmed[..head_end];
3633    head.eq_ignore_ascii_case("INSERT")
3634        || head.eq_ignore_ascii_case("UPDATE")
3635        || head.eq_ignore_ascii_case("DELETE")
3636}
3637
3638fn walk_plan_node(
3639    node: &crate::storage::query::planner::CanonicalLogicalNode,
3640    depth: usize,
3641    out: &mut Vec<crate::storage::query::unified::UnifiedRecord>,
3642    vector_details: bool,
3643) {
3644    use std::sync::Arc;
3645    let mut rec = crate::storage::query::unified::UnifiedRecord::default();
3646    rec.set_arc(Arc::from("op"), Value::text(node.operator.clone()));
3647    rec.set_arc(
3648        Arc::from("source"),
3649        node.source.clone().map(Value::text).unwrap_or(Value::Null),
3650    );
3651    rec.set_arc(
3652        Arc::from("estimated_rows"),
3653        Value::Float(node.estimated_rows),
3654    );
3655    rec.set_arc(
3656        Arc::from("estimated_cost"),
3657        Value::Float(node.operator_cost),
3658    );
3659    rec.set_arc(Arc::from("depth"), Value::Integer(depth as i64));
3660    if vector_details {
3661        for name in ["access_path_reason", "index_hint"] {
3662            rec.set_owned(
3663                name.to_string(),
3664                node.details
3665                    .get(name)
3666                    .cloned()
3667                    .map(Value::text)
3668                    .unwrap_or(Value::Null),
3669            );
3670        }
3671    }
3672    out.push(rec);
3673    for child in &node.children {
3674        walk_plan_node(child, depth + 1, out, vector_details);
3675    }
3676}
3677
3678fn walk_analyze_plan_node(
3679    node: &crate::storage::query::planner::CanonicalLogicalNode,
3680    depth: usize,
3681    affected_rows: u64,
3682    elapsed_ms: f64,
3683    out: &mut Vec<crate::storage::query::unified::UnifiedRecord>,
3684) {
3685    use std::sync::Arc;
3686    let mut rec = crate::storage::query::unified::UnifiedRecord::default();
3687    rec.set_arc(Arc::from("op"), Value::text(node.operator.clone()));
3688    rec.set_arc(
3689        Arc::from("source"),
3690        node.source.clone().map(Value::text).unwrap_or(Value::Null),
3691    );
3692    rec.set_arc(
3693        Arc::from("estimated_rows"),
3694        Value::Float(node.estimated_rows),
3695    );
3696    rec.set_arc(
3697        Arc::from("estimated_cost"),
3698        Value::Float(node.operator_cost),
3699    );
3700    rec.set_arc(
3701        Arc::from("actual_rows"),
3702        Value::UnsignedInteger(if depth == 0 { affected_rows } else { 0 }),
3703    );
3704    rec.set_arc(
3705        Arc::from("actual_ms"),
3706        Value::Float(if depth == 0 { elapsed_ms } else { 0.0 }),
3707    );
3708    rec.set_arc(Arc::from("depth"), Value::Integer(depth as i64));
3709    out.push(rec);
3710
3711    for child in &node.children {
3712        walk_analyze_plan_node(child, depth + 1, 0, 0.0, out);
3713    }
3714}
3715
3716#[cfg(test)]
3717mod declared_model_validation_tests {
3718    use super::*;
3719    use crate::catalog::CollectionModel;
3720    use crate::storage::no_malloc_hot_paths::measure_allocations;
3721    use reddb_rql::ast::KvCommand;
3722
3723    fn get(collection: &str) -> QueryExpr {
3724        QueryExpr::KvCommand(KvCommand::Get {
3725            model: CollectionModel::Kv,
3726            collection: collection.into(),
3727            key: "item".into(),
3728        })
3729    }
3730
3731    #[test]
3732    fn declared_model_validation_keeps_mismatches_and_observes_contract_changes() {
3733        let runtime = RedDBRuntime::in_memory().expect("runtime");
3734        runtime
3735            .execute_query("KV PUT declared.item = 1")
3736            .expect("seed");
3737        runtime
3738            .validate_model_operations_before_auth(&get("declared"))
3739            .expect("KV contract");
3740        let db = runtime.db();
3741        for (model, name) in [
3742            (CollectionModel::Table, "table"),
3743            (CollectionModel::Mixed, "mixed"),
3744        ] {
3745            let mut contract = db.collection_contract("declared").expect("contract");
3746            contract.declared_model = model;
3747            db.save_collection_contract(contract)
3748                .expect("replace contract");
3749            let error = runtime
3750                .validate_model_operations_before_auth(&get("declared"))
3751                .expect_err("mismatch");
3752            assert_eq!(
3753                error.to_string(),
3754                format!("INVALID_OPERATION: model mismatch: expected kv, got {name}")
3755            );
3756        }
3757    }
3758
3759    #[test]
3760    fn declared_model_validation_preserves_legacy_inference_and_missing_collections() {
3761        let runtime = RedDBRuntime::in_memory().expect("runtime");
3762        let db = runtime.db();
3763        let store = db.store();
3764        store
3765            .create_collection("legacy")
3766            .expect("legacy collection");
3767        store
3768            .get_collection("legacy")
3769            .expect("manager")
3770            .insert(crate::storage::UnifiedEntity::table_row(
3771                crate::storage::EntityId::new(1),
3772                "legacy",
3773                1,
3774                Vec::new(),
3775            ))
3776            .expect("uncontracted table row");
3777        assert!(db.collection_contract("legacy").is_none());
3778        let error = runtime
3779            .validate_model_operations_before_auth(&get("legacy"))
3780            .expect_err("inferred table");
3781        assert!(error
3782            .to_string()
3783            .contains("model mismatch: expected kv, got table"));
3784        runtime
3785            .validate_model_operations_before_auth(&get("missing"))
3786            .expect("missing stays deferred to execution");
3787    }
3788
3789    #[test]
3790    fn declared_model_validation_does_not_treat_orphan_contracts_as_collections() {
3791        let runtime = RedDBRuntime::in_memory().expect("runtime");
3792        runtime
3793            .execute_query("KV PUT declared.item = 1")
3794            .expect("seed");
3795        let db = runtime.db();
3796        let mut contract = db.collection_contract("declared").expect("contract");
3797        contract.name = "orphan".into();
3798        contract.declared_model = CollectionModel::Table;
3799        db.save_collection_contract(contract)
3800            .expect("orphan contract");
3801        assert!(db.store().get_collection("orphan").is_none());
3802        runtime
3803            .validate_model_operations_before_auth(&get("orphan"))
3804            .expect("no physical collection to validate");
3805    }
3806
3807    #[test]
3808    fn declared_model_validation_allocates_nothing_with_unrelated_collections() {
3809        let runtime = RedDBRuntime::in_memory().expect("runtime");
3810        runtime
3811            .execute_query("KV PUT declared.item = 1")
3812            .expect("seed");
3813        let expr = get("declared");
3814        for unrelated in [0, 64] {
3815            let db = runtime.db();
3816            let store = db.store();
3817            for index in 0..unrelated {
3818                let name = format!("unrelated_{index}");
3819                store
3820                    .create_collection(&name)
3821                    .expect("unrelated collection");
3822            }
3823            runtime
3824                .validate_model_operations_before_auth(&expr)
3825                .expect("warm contract cache");
3826            let (result, count) =
3827                measure_allocations(|| runtime.validate_model_operations_before_auth(&expr));
3828            result.expect("declared KV");
3829            assert_eq!(
3830                count.allocs, 0,
3831                "validation must only borrow the target contract"
3832            );
3833        }
3834    }
3835}
3836
3837#[cfg(test)]
3838mod multibyte_sql_tests {
3839    use super::*;
3840
3841    #[test]
3842    fn explain_analyze_prefix_ignores_multibyte_text_at_the_keyword_boundary() {
3843        // `strip_keyword_ci` used `split_at(keyword.len())`, which panics when
3844        // that byte offset is inside a multi-byte character.
3845        for pad in 0..16 {
3846            for wide in ["é", "日", "😀"] {
3847                let sql = format!("{}{wide}llo", "a".repeat(pad));
3848                assert_eq!(strip_explain_analyze_prefix(&sql), None, "{sql:?}");
3849            }
3850        }
3851        assert_eq!(
3852            strip_explain_analyze_prefix("explain  analyze DELETE FROM t"),
3853            Some("DELETE FROM t")
3854        );
3855    }
3856
3857    /// One malformed or unusual statement must never take the process down:
3858    /// release builds abort on panic, so any panic reachable from
3859    /// `execute_query` is a remote denial of service. Put a multi-byte
3860    /// character at every byte offset a keyword matcher might slice at.
3861    #[test]
3862    fn multibyte_sql_never_panics_execute_query() {
3863        let rt = RedDBRuntime::in_memory().expect("in-memory runtime");
3864        let seeds = [
3865            "",
3866            "SELECT ",
3867            "SELECT '",
3868            "EXPLAIN ",
3869            "EXPLAIN ANALYZE ",
3870            "CHECKPOINT ",
3871            "CHECKOUT ",
3872            "RESET ",
3873            "MERGE ",
3874            "CHERRY PICK ",
3875            "REVERT ",
3876            "RESOLVE CONFLICT ",
3877            "INSERT INTO t VALUES ('",
3878        ];
3879        let mut panicked = Vec::new();
3880        for seed in seeds {
3881            for pad in 0..16 {
3882                for wide in ["é", "日", "😀"] {
3883                    let sql = format!("{seed}{}{wide}llo", "a".repeat(pad));
3884                    let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
3885                        let _ = rt.execute_query(&sql);
3886                    }));
3887                    if outcome.is_err() {
3888                        panicked.push(sql);
3889                    }
3890                }
3891            }
3892        }
3893        assert!(
3894            panicked.is_empty(),
3895            "execute_query panicked on {} statement(s), first: {:?}",
3896            panicked.len(),
3897            panicked.first()
3898        );
3899    }
3900}
3901
3902#[cfg(test)]
3903mod inline_graph_tvf_tests {
3904    use super::*;
3905
3906    fn scopes_for(sql: &str) -> HashSet<String> {
3907        let expr = reddb_rql::parser::parse(sql).expect("parse").query;
3908        query_expr_result_cache_scopes(&expr)
3909    }
3910
3911    #[test]
3912    fn inline_tvf_cache_scopes_include_source_collections() {
3913        // The result-cache key for the inline form must derive from the
3914        // `nodes`/`edges` source collections so a write to either invalidates
3915        // the cached result (issue #799).
3916        let scopes = scopes_for(
3917            "SELECT * FROM components(nodes => (SELECT id FROM hosts), edges => (SELECT src, dst FROM links))",
3918        );
3919        assert!(scopes.contains("hosts"), "nodes source scoped: {scopes:?}");
3920        assert!(scopes.contains("links"), "edges source scoped: {scopes:?}");
3921    }
3922
3923    #[test]
3924    fn graph_collection_tvf_cache_scope_is_graph_argument() {
3925        // The graph-collection form still materializes the active graph, but
3926        // result-cache invalidation is scoped to the named graph argument so
3927        // INSERT INTO g NODE/EDGE invalidates cached TVF rows.
3928        let scopes = scopes_for("SELECT * FROM components(g)");
3929        assert!(scopes.contains("g"), "collection form scoped: {scopes:?}");
3930    }
3931
3932    #[test]
3933    fn abstract_degree_centrality_counts_undirected_endpoints() {
3934        let nodes = vec!["a".to_string(), "b".to_string(), "c".to_string()];
3935        let edges = vec![
3936            ("a".to_string(), "b".to_string(), 1.0_f32),
3937            ("b".to_string(), "c".to_string(), 1.0_f32),
3938        ];
3939        let degrees = abstract_degree_centrality(&nodes, &edges);
3940        assert_eq!(
3941            degrees,
3942            vec![
3943                ("a".to_string(), 1),
3944                ("b".to_string(), 2),
3945                ("c".to_string(), 1),
3946            ]
3947        );
3948    }
3949}
3950
3951/// Vault keys the engine itself owns. `SET SECRET` / `DELETE SECRET` share
3952/// the vault KV space with the IAM policy blobs, the attachment tables and
3953/// the `SECRET()` column master key; letting a principal with `secret:write`
3954/// overwrite them is an escalation (allow-all policy after restart) or a
3955/// permanent data loss (rotated AES key). Provider credentials under
3956/// `red.secret.ai.*` stay writable — that is the documented use.
3957fn reject_engine_internal_vault_key(key: &str) -> RedDBResult<()> {
3958    let lowered = key.to_ascii_lowercase();
3959    let internal = lowered == "red.secret.aes_key"
3960        || lowered.starts_with("red.iam.")
3961        || lowered.starts_with("red.acl.")
3962        || lowered.starts_with("red.vault.");
3963    if internal {
3964        return Err(RedDBError::Query(format!(
3965            "`{key}` is an engine-internal vault key and cannot be set or deleted through SET/DELETE SECRET"
3966        )));
3967    }
3968    Ok(())
3969}
3970
3971/// Optional confinement for `COPY … FROM '<path>'`. When
3972/// `REDDB_COPY_IMPORT_ROOT` is set, the (canonicalised) source path must live
3973/// under that directory; otherwise the statement is refused. Unset keeps the
3974/// historical behaviour (any readable file, admin-only via the privilege
3975/// gate).
3976fn confine_copy_from_path(path: &str) -> RedDBResult<()> {
3977    let Ok(root) = std::env::var("REDDB_COPY_IMPORT_ROOT") else {
3978        return Ok(());
3979    };
3980    let root = std::fs::canonicalize(&root).map_err(|e| {
3981        RedDBError::Query(format!(
3982            "REDDB_COPY_IMPORT_ROOT `{root}` is not usable: {e}"
3983        ))
3984    })?;
3985    let resolved = std::fs::canonicalize(path)
3986        .map_err(|e| RedDBError::Query(format!("COPY FROM `{path}`: {e}")))?;
3987    if !resolved.starts_with(&root) || !resolved.is_file() {
3988        return Err(RedDBError::Query(format!(
3989            "COPY FROM `{path}` is outside the configured import root"
3990        )));
3991    }
3992    Ok(())
3993}
3994
3995#[cfg(test)]
3996mod result_cache_execution_tests {
3997    use super::*;
3998
3999    const QUERY: &str = "SELECT SUM(n) AS n FROM result_cache_probe";
4000
4001    fn runtime_with_rows(cache_enabled: bool) -> RedDBRuntime {
4002        let runtime = RedDBRuntime::in_memory().expect("runtime");
4003        runtime
4004            .execute_query("CREATE TABLE result_cache_probe (id INT, n INT)")
4005            .expect("create table");
4006        runtime
4007            .execute_query(
4008                "INSERT INTO result_cache_probe (id, n) VALUES (1, 10), (2, 20), (3, 30)",
4009            )
4010            .expect("seed rows");
4011        runtime
4012            .execute_query(&format!(
4013                "SET CONFIG runtime.result_cache.enabled = {cache_enabled}"
4014            ))
4015            .expect("configure result cache");
4016        runtime
4017    }
4018
4019    fn aggregate_value(runtime: &RedDBRuntime) -> i64 {
4020        let result = runtime.execute_query(QUERY).expect("aggregate query");
4021        match result.result.records[0].get("n") {
4022            Some(reddb_types::Value::Integer(value)) => *value,
4023            other => panic!("expected integer aggregate, got {other:?}"),
4024        }
4025    }
4026
4027    #[test]
4028    fn hot_hit_bypasses_query_execution_pipeline() {
4029        let runtime = runtime_with_rows(true);
4030        reset_result_cache_miss_executions();
4031        let before = runtime.result_cache_metrics();
4032
4033        assert_eq!(aggregate_value(&runtime), 60);
4034        assert_eq!(result_cache_miss_executions(), 1);
4035        assert_eq!(aggregate_value(&runtime), 60);
4036
4037        let after = runtime.result_cache_metrics();
4038        assert_eq!(
4039            result_cache_miss_executions(),
4040            1,
4041            "hit re-entered the executor"
4042        );
4043        assert_eq!(after.0 - before.0, 1, "second read must be a cache hit");
4044        assert_eq!(after.1 - before.1, 1, "first read must populate the cache");
4045    }
4046
4047    #[test]
4048    fn disabled_cache_executes_every_read() {
4049        let runtime = runtime_with_rows(false);
4050        reset_result_cache_miss_executions();
4051        let before = runtime.result_cache_metrics();
4052
4053        assert_eq!(aggregate_value(&runtime), 60);
4054        assert_eq!(aggregate_value(&runtime), 60);
4055
4056        assert_eq!(result_cache_miss_executions(), 2);
4057        assert_eq!(
4058            runtime.result_cache_metrics(),
4059            before,
4060            "disabled cache must not report lookups"
4061        );
4062    }
4063
4064    #[test]
4065    fn table_write_forces_one_reexecution_then_hits_again() {
4066        let runtime = runtime_with_rows(true);
4067        assert_eq!(aggregate_value(&runtime), 60);
4068        assert_eq!(aggregate_value(&runtime), 60);
4069        runtime
4070            .execute_query("INSERT INTO result_cache_probe (id, n) VALUES (4, 40)")
4071            .expect("mutate cached table");
4072        reset_result_cache_miss_executions();
4073        let before = runtime.result_cache_metrics();
4074
4075        assert_eq!(aggregate_value(&runtime), 100);
4076        assert_eq!(result_cache_miss_executions(), 1);
4077        assert_eq!(aggregate_value(&runtime), 100);
4078
4079        let after = runtime.result_cache_metrics();
4080        assert_eq!(result_cache_miss_executions(), 1);
4081        assert_eq!(after.0 - before.0, 1, "post-write result must become hot");
4082        assert_eq!(
4083            after.1 - before.1,
4084            1,
4085            "write must invalidate the old result"
4086        );
4087    }
4088}
4089
4090#[cfg(test)]
4091mod security_gate_tests {
4092    use super::*;
4093    use crate::auth::store::AuthStore;
4094    use crate::auth::{AuthConfig, Role};
4095
4096    /// Clears the thread-local identity on drop so one test's principal
4097    /// never leaks into the next on a shared thread.
4098    struct IdentityGuard;
4099
4100    impl IdentityGuard {
4101        fn install(username: &str, role: Role) -> Self {
4102            set_current_auth_identity(username.to_string(), role);
4103            Self
4104        }
4105    }
4106
4107    impl Drop for IdentityGuard {
4108        fn drop(&mut self) {
4109            clear_current_auth_identity();
4110        }
4111    }
4112
4113    struct ScopedIdentityGuard;
4114
4115    impl ScopedIdentityGuard {
4116        fn install(tenant: &str, username: &str, role: Role) -> Self {
4117            crate::runtime::execution_context::set_current_auth_principal(
4118                crate::auth::UserId::scoped(tenant, username),
4119                role,
4120            );
4121            set_current_tenant(tenant.to_string());
4122            Self
4123        }
4124    }
4125
4126    impl Drop for ScopedIdentityGuard {
4127        fn drop(&mut self) {
4128            clear_current_auth_identity();
4129            clear_current_tenant();
4130        }
4131    }
4132
4133    fn runtime_with_user(username: &str, role: Role) -> RedDBRuntime {
4134        let store = std::sync::Arc::new(AuthStore::new(AuthConfig {
4135            enabled: true,
4136            session_ttl_secs: 60,
4137            require_auth: true,
4138            auto_encrypt_storage: false,
4139            vault_enabled: false,
4140            cert: Default::default(),
4141            oauth: Default::default(),
4142        }));
4143        store.create_user(username, "pw", role).expect("user");
4144        let runtime =
4145            RedDBRuntime::with_options(crate::api::RedDBOptions::in_memory()).expect("runtime");
4146        runtime.set_auth_store(store);
4147        runtime
4148    }
4149
4150    fn runtime_with_store(username: &str, role: Role) -> (RedDBRuntime, std::sync::Arc<AuthStore>) {
4151        let store = std::sync::Arc::new(AuthStore::new(AuthConfig {
4152            enabled: true,
4153            session_ttl_secs: 60,
4154            require_auth: true,
4155            auto_encrypt_storage: false,
4156            vault_enabled: false,
4157            cert: Default::default(),
4158            oauth: Default::default(),
4159        }));
4160        store.create_user(username, "pw", role).expect("user");
4161        let runtime =
4162            RedDBRuntime::with_options(crate::api::RedDBOptions::in_memory()).expect("runtime");
4163        runtime.set_auth_store(std::sync::Arc::clone(&store));
4164        (runtime, store)
4165    }
4166
4167    #[test]
4168    fn tenant_credential_cannot_override_credential_scope() {
4169        use crate::runtime::within_clause::{FieldOverride, ScopeOverride};
4170
4171        let store = std::sync::Arc::new(AuthStore::new(AuthConfig {
4172            enabled: true,
4173            require_auth: true,
4174            ..AuthConfig::default()
4175        }));
4176        store
4177            .create_user_in_tenant(Some("acme"), "admin", "pw", Role::Admin)
4178            .expect("tenant admin");
4179        let runtime =
4180            RedDBRuntime::with_options(crate::api::RedDBOptions::in_memory()).expect("runtime");
4181        runtime.set_auth_store(store);
4182        let _identity = ScopedIdentityGuard::install("acme", "admin", Role::Admin);
4183
4184        runtime
4185            .execute_query("WITHIN TENANT 'acme' SELECT 1")
4186            .expect("own tenant remains valid");
4187        for sql in [
4188            "WITHIN TENANT 'globex' SELECT 1",
4189            "WITHIN TENANT NULL SELECT 1",
4190            "WITHIN TENANT 'acme' USER 'other' SELECT 1",
4191            "WITHIN TENANT 'acme' AS ROLE 'read' SELECT 1",
4192        ] {
4193            assert!(runtime.execute_query(sql).is_err(), "must reject {sql}");
4194        }
4195        assert!(runtime
4196            .execute_query_with_scope(
4197                "SELECT 1",
4198                ScopeOverride {
4199                    tenant: FieldOverride::Set("globex".to_string()),
4200                    ..ScopeOverride::default()
4201                },
4202            )
4203            .is_err());
4204
4205        set_current_connection_id(44_201);
4206        runtime.execute_query("BEGIN").expect("begin");
4207        assert!(runtime.execute_query("SET LOCAL TENANT 'globex'").is_err());
4208        runtime.execute_query("ROLLBACK").expect("rollback");
4209        clear_current_connection_id();
4210    }
4211
4212    #[test]
4213    fn tenant_admin_sql_user_lifecycle_is_tenant_bounded() {
4214        let store = std::sync::Arc::new(AuthStore::new(AuthConfig {
4215            enabled: true,
4216            require_auth: true,
4217            ..AuthConfig::default()
4218        }));
4219        store
4220            .create_user_in_tenant(Some("acme"), "admin", "pw", Role::Admin)
4221            .expect("tenant admin");
4222        let runtime =
4223            RedDBRuntime::with_options(crate::api::RedDBOptions::in_memory()).expect("runtime");
4224        runtime.set_auth_store(std::sync::Arc::clone(&store));
4225        let _identity = ScopedIdentityGuard::install("acme", "admin", Role::Admin);
4226
4227        runtime
4228            .execute_query("CREATE USER acme.alice PASSWORD 'pw' ROLE read")
4229            .expect("same-tenant create");
4230        assert!(runtime
4231            .execute_query("CREATE USER globex.alice PASSWORD 'pw' ROLE read")
4232            .is_err());
4233        assert!(runtime
4234            .execute_query("ALTER USER globex.alice PASSWORD 'owned'")
4235            .is_err());
4236        let cross_tenant_allow = crate::auth::policies::Policy::from_json_str(
4237            r#"{
4238                "id":"tenant-cross-user",
4239                "version":1,
4240                "statements":[{
4241                    "effect":"allow",
4242                    "actions":["user:create","user:password:change"],
4243                    "resources":["user:tenant/globex/*"]
4244                }]
4245            }"#,
4246        )
4247        .expect("cross-tenant policy");
4248        store.put_policy(cross_tenant_allow).expect("put policy");
4249        store
4250            .attach_policy(
4251                crate::auth::store::PrincipalRef::User(crate::auth::UserId::scoped(
4252                    "acme", "admin",
4253                )),
4254                "tenant-cross-user",
4255            )
4256            .expect("attach policy");
4257        assert!(runtime
4258            .execute_query("CREATE USER globex.bob PASSWORD 'pw' ROLE read")
4259            .is_err());
4260        assert!(store.get_user(Some("globex"), "alice").is_none());
4261        assert!(store.get_user(Some("globex"), "bob").is_none());
4262    }
4263
4264    #[test]
4265    fn slow_log_redacts_user_password_statements() {
4266        for sql in [
4267            "CREATE USER alice PASSWORD 'super-secret' ROLE admin",
4268            "CREATE USER acme.alice PASSWORD 'super-secret' ROLE read",
4269            "ALTER USER alice PASSWORD 'super-secret'",
4270            "AlTeR UsEr acme.alice PaSsWoRd 'super-secret'",
4271            "EXPLAIN CREATE USER alice PASSWORD 'super-secret' ROLE read",
4272            "EXPLAIN ALTER USER alice PASSWORD 'super-secret'",
4273            "ALTER USER alice PASSWORD 'unterminated",
4274        ] {
4275            let redacted = redact_sql_for_slow_log(sql);
4276            assert!(!redacted.contains("super-secret"), "leaked from {sql}");
4277            assert!(!redacted.contains("unterminated"), "leaked from {sql}");
4278        }
4279        assert_eq!(
4280            redact_sql_for_slow_log("SELECT password FROM accounts"),
4281            "SELECT password FROM accounts"
4282        );
4283    }
4284
4285    #[test]
4286    fn nested_reads_need_select_on_the_inner_table() {
4287        use crate::auth::privileges::{Action, GrantPrincipal, Resource};
4288        use crate::auth::UserId;
4289        let (runtime, store) = runtime_with_store("ro", Role::Read);
4290        runtime
4291            .execute_query("CREATE TABLE gate_open (a INTEGER)")
4292            .expect("create");
4293        runtime
4294            .execute_query("CREATE TABLE gate_closed (b INTEGER)")
4295            .expect("create");
4296        // One explicit grant switches the legacy fallback off, so `ro`
4297        // may read exactly `gate_open`.
4298        store
4299            .grant(
4300                &UserId::from_parts(None, "admin"),
4301                Role::Admin,
4302                GrantPrincipal::User(UserId::from_parts(None, "ro")),
4303                Resource::table_from_name("gate_open"),
4304                vec![Action::Select],
4305                false,
4306                None,
4307            )
4308            .expect("grant");
4309        let _identity = IdentityGuard::install("ro", Role::Read);
4310        runtime
4311            .execute_query("SELECT * FROM (SELECT a FROM gate_open) s")
4312            .expect("derived table over a granted table is fine");
4313        // The outer statement names only the synthetic subquery table, so
4314        // the inner table used to be read with no check at all.
4315        for sql in [
4316            "SELECT * FROM (SELECT b FROM gate_closed) s",
4317            "SELECT a FROM gate_open WHERE a IN (SELECT b FROM gate_closed)",
4318        ] {
4319            let err = runtime
4320                .execute_query(sql)
4321                .expect_err("nested read of an ungranted table must be refused");
4322            assert!(
4323                err.to_string().contains("permission denied"),
4324                "{sql}: unexpected error: {err}"
4325            );
4326        }
4327    }
4328
4329    #[test]
4330    fn policy_listings_are_self_or_policy_management() {
4331        let (runtime, store) = runtime_with_store("ro", Role::Read);
4332        store.create_user("other", "pw", Role::Read).expect("user");
4333        let _identity = IdentityGuard::install("ro", Role::Read);
4334        runtime
4335            .execute_query("SHOW EFFECTIVE PERMISSIONS FOR ro")
4336            .expect("a principal may inspect its own permissions");
4337        let err = runtime
4338            .execute_query("SHOW EFFECTIVE PERMISSIONS FOR other")
4339            .expect_err("inspecting another principal is policy management");
4340        assert!(
4341            err.to_string().contains("permission denied"),
4342            "unexpected error: {err}"
4343        );
4344    }
4345
4346    #[test]
4347    fn secret_columns_stay_masked_for_readers_without_secret_read() {
4348        use reddb_types::Value;
4349        let (runtime, store) = runtime_with_store("ro", Role::Read);
4350        store.create_user("root", "pw", Role::Admin).expect("admin");
4351        store.ensure_vault_secret_key();
4352        runtime
4353            .execute_query(
4354                "INSERT INTO creds_gate (name, token) VALUES ('stripe', SECRET('sk_live_abc'))",
4355            )
4356            .expect("insert");
4357
4358        let token_for = |username: &str, role: Role| {
4359            let _identity = IdentityGuard::install(username, role);
4360            let result = runtime
4361                .execute_query("SELECT name, token FROM creds_gate")
4362                .expect("select");
4363            result.result.records[0]
4364                .get("token")
4365                .cloned()
4366                .expect("token column")
4367        };
4368        // Ordinary SELECT stays masked for every role. Plaintext retrieval
4369        // requires an explicit reveal operation.
4370        assert!(
4371            matches!(token_for("ro", Role::Read), Value::Secret(_)),
4372            "a read-role principal must see the column masked"
4373        );
4374        assert!(matches!(token_for("root", Role::Admin), Value::Secret(_)));
4375    }
4376
4377    #[test]
4378    fn store_wide_operations_need_the_admin_tier() {
4379        let runtime = runtime_with_user("bob", Role::Write);
4380        let _identity = IdentityGuard::install("bob", Role::Write);
4381        // These fell through the gate's wildcard arm, so a write-role
4382        // principal could scrub or vacuum the store.
4383        for sql in ["VACUUM", "SCRUB"] {
4384            let err = runtime
4385                .execute_query(sql)
4386                .expect_err("store-wide operation must be refused for a non-admin");
4387            assert!(
4388                err.to_string().contains("permission denied"),
4389                "{sql}: unexpected error: {err}"
4390            );
4391        }
4392    }
4393
4394    #[test]
4395    fn explain_needs_the_privilege_of_the_statement_it_plans() {
4396        let runtime = runtime_with_user("ro", Role::Read);
4397        let _identity = IdentityGuard::install("ro", Role::Read);
4398        runtime
4399            .execute_query("EXPLAIN SELECT 1 AS one")
4400            .expect("planning a read is a read");
4401        // EXPLAIN used to bypass the gate entirely, so a read-only
4402        // principal could plan any write and read the schema back.
4403        let err = runtime
4404            .execute_query("EXPLAIN INSERT INTO gate_t (a) VALUES (1)")
4405            .expect_err("planning a write needs the write privilege");
4406        assert!(
4407            err.to_string().contains("permission denied"),
4408            "unexpected EXPLAIN error: {err}"
4409        );
4410    }
4411
4412    #[test]
4413    fn copy_from_a_server_path_is_admin_only() {
4414        let runtime = runtime_with_user("bob", Role::Write);
4415        let _identity = IdentityGuard::install("bob", Role::Write);
4416        // `COPY … FROM '<path>'` opens a file on the *server*, so a
4417        // write-role principal used to be able to read /proc/self/environ
4418        // (admin token, vault certificate, cloud keys) into a table.
4419        let err = runtime
4420            .execute_query("COPY leak FROM '/etc/hostname'")
4421            .expect_err("COPY FROM must be refused for a non-admin");
4422        assert!(
4423            err.to_string().contains("permission denied"),
4424            "unexpected COPY error: {err}"
4425        );
4426    }
4427
4428    #[test]
4429    fn vcs_history_rewrites_are_admin_only() {
4430        let runtime = runtime_with_user("bob", Role::Write);
4431        let _identity = IdentityGuard::install("bob", Role::Write);
4432        // VCS commands were dispatched before the statement frame was built,
4433        // so `RESET HARD TO` ran with no privilege check at all.
4434        let err = runtime
4435            .execute_query("RESET HARD TO 'deadbeef'")
4436            .expect_err("RESET must be refused for a non-admin");
4437        assert!(
4438            err.to_string().contains("permission denied"),
4439            "unexpected RESET error: {err}"
4440        );
4441    }
4442
4443    #[test]
4444    fn ai_provider_config_writes_are_admin_only() {
4445        let runtime = runtime_with_user("bob", Role::Write);
4446        let _identity = IdentityGuard::install("bob", Role::Write);
4447        // This namespace decides which vault secret is sent, as a bearer, to
4448        // which host — the exfiltration primitive behind the SET CONFIG gap.
4449        let err = runtime
4450            .execute_query(
4451                "SET CONFIG ai.providers.openai.tokens.default.secret_ref = 'red.secret.aes_key'",
4452            )
4453            .expect_err("AI provider config must be refused for a non-admin");
4454        assert!(
4455            err.to_string().contains("permission denied"),
4456            "unexpected SET CONFIG error: {err}"
4457        );
4458    }
4459
4460    #[test]
4461    fn engine_internal_vault_keys_are_not_writable_through_set_secret() {
4462        let runtime = runtime_with_user("root", Role::Admin);
4463        let _identity = IdentityGuard::install("root", Role::Admin);
4464        // Even an admin must not overwrite the IAM policy blob (an allow-all
4465        // policy would survive a restart) or the `SECRET()` master key
4466        // (rotating it destroys every encrypted column value).
4467        for key in [
4468            "red.iam.policies",
4469            "red.iam.attachments.users",
4470            "red.secret.aes_key",
4471        ] {
4472            let err = runtime
4473                .execute_query(&format!("SET SECRET {key} = 'x'"))
4474                .expect_err("engine-internal vault key must be refused");
4475            assert!(
4476                err.to_string().contains("engine-internal"),
4477                "unexpected error for {key}: {err}"
4478            );
4479        }
4480    }
4481}