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.execute_insert(query, insert),
1072            QueryExpr::Update(ref update) => self
1073                .with_deferred_store_wal_for_dml(self.update_may_emit_events(update), || {
1074                    self.execute_update(query, update)
1075                }),
1076            QueryExpr::Delete(ref delete) => self
1077                .with_deferred_store_wal_for_dml(self.delete_may_emit_events(delete), || {
1078                    self.execute_delete(query, delete)
1079                }),
1080            // DDL execution
1081            QueryExpr::CreateTable(ref create) => self.execute_create_table(query, create),
1082            QueryExpr::CreateCollection(ref create) => {
1083                self.execute_create_collection(query, create)
1084            }
1085            QueryExpr::CreateVector(ref create) => self.execute_create_vector(query, create),
1086            QueryExpr::DropTable(ref drop_tbl) => self.execute_drop_table(query, drop_tbl),
1087            QueryExpr::DropGraph(ref drop_graph) => self.execute_drop_graph(query, drop_graph),
1088            QueryExpr::DropVector(ref drop_vector) => self.execute_drop_vector(query, drop_vector),
1089            QueryExpr::DropDocument(ref drop_document) => {
1090                self.execute_drop_document(query, drop_document)
1091            }
1092            QueryExpr::DropKv(ref drop_kv) => self.execute_drop_kv(query, drop_kv),
1093            QueryExpr::DropCollection(ref drop_collection) => {
1094                self.execute_drop_collection(query, drop_collection)
1095            }
1096            QueryExpr::Truncate(ref truncate) => self.execute_truncate(query, truncate),
1097            QueryExpr::AlterTable(ref alter) => self.execute_alter_table(query, alter),
1098            QueryExpr::CreateVcsRef(ref create) => self.execute_create_vcs_ref(query, create),
1099            QueryExpr::DropVcsRef(ref drop_ref) => self.execute_drop_vcs_ref(query, drop_ref),
1100            QueryExpr::ForkStore(ref fork) => self.execute_fork_store(query, fork),
1101            QueryExpr::PromoteFork(ref promote_fork) => {
1102                self.execute_promote_fork(query, promote_fork)
1103            }
1104            QueryExpr::DropFork(ref drop_fork) => self.execute_drop_fork(query, drop_fork),
1105            QueryExpr::ExplainAlter(ref explain) => self.execute_explain_alter(query, explain),
1106            // Graph analytics commands
1107            QueryExpr::GraphCommand(ref cmd) => self.execute_graph_command(query, cmd),
1108            // Search commands
1109            QueryExpr::SearchCommand(ref cmd) => self.execute_search_command(query, cmd),
1110            // ASK: RAG query with LLM synthesis
1111            QueryExpr::Ask(ref ask) => self.execute_ask(query, ask),
1112            QueryExpr::CreateIndex(ref create_idx) => self.execute_create_index(query, create_idx),
1113            QueryExpr::DropIndex(ref drop_idx) => self.execute_drop_index(query, drop_idx),
1114            QueryExpr::ProbabilisticCommand(ref cmd) => {
1115                self.execute_probabilistic_command(query, cmd)
1116            }
1117            // Time-series DDL
1118            QueryExpr::CreateTimeSeries(ref ts) => self.execute_create_timeseries(query, ts),
1119            QueryExpr::CreateMetric(ref metric) => self.execute_create_metric(query, metric),
1120            QueryExpr::AlterMetric(ref alter) => self.execute_alter_metric(query, alter),
1121            QueryExpr::CreateSlo(ref slo) => self.execute_create_slo(query, slo),
1122            QueryExpr::DropTimeSeries(ref ts) => self.execute_drop_timeseries(query, ts),
1123            // Queue DDL and commands
1124            QueryExpr::CreateQueue(ref q) => self.execute_create_queue(query, q),
1125            QueryExpr::AlterQueue(ref q) => self.execute_alter_queue(query, q),
1126            QueryExpr::DropQueue(ref q) => self.execute_drop_queue(query, q),
1127            QueryExpr::QueueSelect(ref q) => self.execute_queue_select(query, q),
1128            QueryExpr::QueueCommand(ref cmd) => self
1129                .with_deferred_store_wal_if_transaction(|| self.execute_queue_command(query, cmd)),
1130            QueryExpr::EventsBackfill(ref backfill) => {
1131                self.execute_events_backfill(query, backfill)
1132            }
1133            QueryExpr::EventsBackfillStatus { ref collection } => Err(RedDBError::Query(format!(
1134                "EVENTS BACKFILL STATUS for '{collection}' is not implemented in this slice"
1135            ))),
1136            QueryExpr::KvCommand(ref cmd) => self.execute_kv_command(query, cmd),
1137            QueryExpr::ConfigCommand(ref cmd) => self.execute_config_command(query, cmd),
1138            QueryExpr::CreateTree(ref tree) => self.execute_create_tree(query, tree),
1139            QueryExpr::DropTree(ref tree) => self.execute_drop_tree(query, tree),
1140            QueryExpr::TreeCommand(ref cmd) => self.execute_tree_command(query, cmd),
1141            // SET CONFIG key = value
1142            QueryExpr::SetConfig { ref key, ref value } => {
1143                if key.starts_with("red.secret.") {
1144                    return Err(RedDBError::Query(
1145                        "red.secret.* is reserved for vault secrets; use SET SECRET".to_string(),
1146                    ));
1147                }
1148                if key.starts_with("red.secrets.") {
1149                    return Err(RedDBError::Query(
1150                        "red.secrets.* is reserved for vault secrets; use SET SECRET".to_string(),
1151                    ));
1152                }
1153                if key.starts_with("red.") {
1154                    return Err(RedDBError::Query(format!(
1155                        "config key `{key}` is invalid inside red.config: omit the redundant `red.` prefix"
1156                    )));
1157                }
1158                // ADR-0068 §5 clean break: reject the removed AI config keys
1159                // (old `default.*` provider/model and per-alias base_url shape)
1160                // with a didactic error naming the replacement key.
1161                crate::ai::validate_ai_config_key_on_write(key)?;
1162                // The managed-config guardrail runs first: it owns the
1163                // didactic error and the `config:write` control event for
1164                // registry-managed keys. The generic capability gate below
1165                // then covers every other key.
1166                match self.check_managed_config_write_for_set_config(key) {
1167                    Err(err) => Err(err),
1168                    Ok(()) => {
1169                        self.check_config_write_privilege(key)?;
1170                        let store = self.inner.db.store();
1171                        let json_val = match value {
1172                            Value::Text(s) => crate::serde_json::Value::String(s.to_string()),
1173                            Value::Integer(n) => crate::serde_json::Value::Number(*n as f64),
1174                            Value::Float(n) => crate::serde_json::Value::Number(*n),
1175                            Value::Boolean(b) => crate::serde_json::Value::Bool(*b),
1176                            _ => crate::serde_json::Value::String(value.to_string()),
1177                        };
1178                        store
1179                            .try_set_config_tree(key, &json_val)
1180                            .map_err(RedDBError::from)?;
1181                        update_current_config_value(key, value.clone());
1182                        // Config changes can flip runtime behavior mid-session
1183                        // (auto_decrypt, auto_encrypt, etc.) — invalidate the
1184                        // result cache so subsequent reads re-execute against
1185                        // the new config.
1186                        self.invalidate_result_cache();
1187                        Ok(RuntimeQueryResult::ok_message(
1188                            query.to_string(),
1189                            &format!("config set: {key}"),
1190                            "set",
1191                        ))
1192                    }
1193                }
1194            }
1195            // SET SECRET key = value
1196            QueryExpr::SetSecret { ref key, ref value } => {
1197                if key.starts_with("red.config.") {
1198                    return Err(RedDBError::Query(
1199                        "red.config.* is reserved for config; use SET CONFIG".to_string(),
1200                    ));
1201                }
1202                reject_engine_internal_vault_key(key)?;
1203                if key.starts_with("red.secret.") {
1204                    self.set_internal_provider_secret(
1205                        key,
1206                        if value.is_null() {
1207                            None
1208                        } else {
1209                            Some(secret_sql_value_to_string(value)?)
1210                        },
1211                    )?;
1212                    return Ok(RuntimeQueryResult::ok_message(
1213                        query.to_string(),
1214                        &format!("secret set: {key}"),
1215                        "set_secret",
1216                    ));
1217                }
1218                let value = if value.is_null() {
1219                    None
1220                } else {
1221                    Some(secret_sql_value_to_string(value)?)
1222                };
1223                self.set_user_secret(key, value.clone())?;
1224                update_current_secret_value(key, value);
1225                Ok(RuntimeQueryResult::ok_message(
1226                    query.to_string(),
1227                    &format!("secret set: {key}"),
1228                    "set_secret",
1229                ))
1230            }
1231            // DELETE SECRET key
1232            QueryExpr::DeleteSecret { ref key } => {
1233                reject_engine_internal_vault_key(key)?;
1234                if key.starts_with("red.secret.") {
1235                    self.set_internal_provider_secret(key, None)?;
1236                    return Ok(RuntimeQueryResult::ok_message(
1237                        query.to_string(),
1238                        &format!("secret deleted: {key}"),
1239                        "delete_secret",
1240                    ));
1241                }
1242                self.set_user_secret(key, None)?;
1243                update_current_secret_value(key, None);
1244                Ok(RuntimeQueryResult::ok_message(
1245                    query.to_string(),
1246                    &format!("secret deleted: {key}"),
1247                    "delete_secret",
1248                ))
1249            }
1250            // SET KV key = value — plain (non-encrypted) user KV entry (#1602)
1251            QueryExpr::SetKv { ref key, ref value } => {
1252                let auth_store = self.inner.auth_store.read().clone().ok_or_else(|| {
1253                    RedDBError::Query("SET KV requires an auth store".to_string())
1254                })?;
1255                self.check_kv_write_privilege(&auth_store, key)?;
1256                // `SET KV k = NULL` deletes, mirroring `SET SECRET`.
1257                if matches!(value, Value::Null) {
1258                    auth_store.plain_kv_delete(key);
1259                    update_current_kv_value(key, None);
1260                    self.invalidate_result_cache();
1261                    return Ok(RuntimeQueryResult::ok_message(
1262                        query.to_string(),
1263                        &format!("kv deleted: {key}"),
1264                        "delete_kv",
1265                    ));
1266                }
1267                let value = secret_sql_value_to_string(value)?;
1268                auth_store.plain_kv_set(key.clone(), value.clone());
1269                update_current_kv_value(key, Some(value));
1270                self.invalidate_result_cache();
1271                Ok(RuntimeQueryResult::ok_message(
1272                    query.to_string(),
1273                    &format!("kv set: {key}"),
1274                    "set_kv",
1275                ))
1276            }
1277            // DELETE KV key
1278            QueryExpr::DeleteKv { ref key } => {
1279                let auth_store = self.inner.auth_store.read().clone().ok_or_else(|| {
1280                    RedDBError::Query("DELETE KV requires an auth store".to_string())
1281                })?;
1282                self.check_kv_write_privilege(&auth_store, key)?;
1283                let deleted = auth_store.plain_kv_delete(key);
1284                if deleted {
1285                    update_current_kv_value(key, None);
1286                }
1287                self.invalidate_result_cache();
1288                Ok(RuntimeQueryResult::ok_message(
1289                    query.to_string(),
1290                    &format!("kv deleted: {key}"),
1291                    if deleted {
1292                        "delete_kv"
1293                    } else {
1294                        "delete_kv_not_found"
1295                    },
1296                ))
1297            }
1298            QueryExpr::Scrub { background, budget } => {
1299                self.execute_scrub_query(query, mode, background, budget)
1300            }
1301            // SHOW SECRET[S] [prefix]
1302            QueryExpr::ShowSecrets { ref prefix } => {
1303                let result = self.show_user_secrets(prefix.as_deref())?;
1304                Ok(RuntimeQueryResult {
1305                    query: query.to_string(),
1306                    mode,
1307                    statement: "show_secrets",
1308                    engine: "runtime-secret",
1309                    result,
1310                    affected_rows: 0,
1311                    statement_type: "select",
1312                    bookmark: None,
1313                    notice: None,
1314                })
1315            }
1316            // SHOW CONFIG [prefix] [AS JSON|FORMAT JSON]
1317            QueryExpr::ShowConfig {
1318                ref prefix,
1319                as_json,
1320            } => {
1321                if let Some(prefix) = prefix {
1322                    super::impl_config::validate_red_config_content_key("red.config", prefix)?;
1323                }
1324                self.check_config_read_privilege()?;
1325                let store = self.inner.db.store();
1326                let all_collections = store.list_collections();
1327                if !all_collections.contains(&"red.config".to_string()) {
1328                    if as_json {
1329                        return Ok(show_config_json_result(
1330                            query,
1331                            mode,
1332                            prefix,
1333                            crate::serde_json::Value::Object(crate::serde_json::Map::new()),
1334                        ));
1335                    }
1336                    let result = UnifiedResult::with_columns(vec!["key".into(), "value".into()]);
1337                    return Ok(RuntimeQueryResult {
1338                        query: query.to_string(),
1339                        mode,
1340                        statement: "show_config",
1341                        engine: "runtime-config",
1342                        result,
1343                        affected_rows: 0,
1344                        statement_type: "select",
1345                        bookmark: None,
1346                        notice: None,
1347                    });
1348                }
1349                let manager = store
1350                    .get_collection("red.config")
1351                    .ok_or_else(|| RedDBError::NotFound("red.config".to_string()))?;
1352                let entities = manager.query_all(|_| true);
1353                let mut latest = std::collections::BTreeMap::<String, (u64, Value, Value)>::new();
1354                for entity in entities {
1355                    if let EntityData::Row(ref row) = entity.data {
1356                        if let Some(ref named) = row.named {
1357                            let key_val = named.get("key").cloned().unwrap_or(Value::Null);
1358                            let val = named.get("value").cloned().unwrap_or(Value::Null);
1359                            let key_str = match &key_val {
1360                                Value::Text(s) => s.as_ref(),
1361                                _ => continue,
1362                            };
1363                            if key_str == super::function_catalog::REGISTRY_KEY {
1364                                continue;
1365                            }
1366                            if let Some(ref pfx) = prefix {
1367                                if !key_str.starts_with(pfx.as_str()) {
1368                                    continue;
1369                                }
1370                            }
1371                            let entity_id = entity.id.raw();
1372                            match latest.get(key_str) {
1373                                Some((prev_id, _, _)) if *prev_id > entity_id => {}
1374                                _ => {
1375                                    latest.insert(key_str.to_string(), (entity_id, key_val, val));
1376                                }
1377                            }
1378                        }
1379                    }
1380                }
1381                if as_json {
1382                    let mut tree = crate::serde_json::Value::Object(crate::serde_json::Map::new());
1383                    for (key, (_, _, val)) in latest {
1384                        let relative = match prefix {
1385                            Some(pfx) if key == *pfx => "",
1386                            Some(pfx) => key
1387                                .strip_prefix(pfx.as_str())
1388                                .and_then(|tail| tail.strip_prefix('.'))
1389                                .unwrap_or(key.as_str()),
1390                            None => key.as_str(),
1391                        };
1392                        insert_config_json_path(&mut tree, relative, val.to_json());
1393                    }
1394                    return Ok(show_config_json_result(query, mode, prefix, tree));
1395                }
1396                let mut result = UnifiedResult::with_columns(vec!["key".into(), "value".into()]);
1397                for (_, key_val, val) in latest.into_values() {
1398                    let mut record = UnifiedRecord::new();
1399                    record.set("key", key_val);
1400                    record.set("value", val);
1401                    result.push(record);
1402                }
1403                Ok(RuntimeQueryResult {
1404                    query: query.to_string(),
1405                    mode,
1406                    statement: "show_config",
1407                    engine: "runtime-config",
1408                    result,
1409                    affected_rows: 0,
1410                    statement_type: "select",
1411                    bookmark: None,
1412                    notice: None,
1413                })
1414            }
1415            // Session-local multi-tenancy handle (Phase 2.5.3).
1416            //
1417            // SET TENANT 'id' / SET TENANT NULL / RESET TENANT — writes
1418            // the thread-local; SHOW TENANT returns it. Paired with the
1419            // CURRENT_TENANT() scalar for use in RLS policies.
1420            QueryExpr::SetTenant(ref value) => {
1421                self.check_set_tenant_privilege(value.as_deref())?;
1422                match value {
1423                    Some(id) => set_current_tenant(id.clone()),
1424                    None => clear_current_tenant(),
1425                }
1426                Ok(RuntimeQueryResult::ok_message(
1427                    query.to_string(),
1428                    &match value {
1429                        Some(id) => format!("tenant set: {id}"),
1430                        None => "tenant cleared".to_string(),
1431                    },
1432                    "set_tenant",
1433                ))
1434            }
1435            QueryExpr::ShowTenant => {
1436                let mut result = UnifiedResult::with_columns(vec!["tenant".into()]);
1437                let mut record = UnifiedRecord::new();
1438                record.set(
1439                    "tenant",
1440                    current_tenant().map(Value::text).unwrap_or(Value::Null),
1441                );
1442                result.push(record);
1443                Ok(RuntimeQueryResult {
1444                    query: query.to_string(),
1445                    mode,
1446                    statement: "show_tenant",
1447                    engine: "runtime-tenant",
1448                    result,
1449                    affected_rows: 0,
1450                    statement_type: "select",
1451                    bookmark: None,
1452                    notice: None,
1453                })
1454            }
1455            // Transaction control (Phase 2.3 PG parity).
1456            //
1457            // BEGIN allocates a real `Xid` and stores a `TxnContext` keyed by
1458            // the current connection's id. COMMIT/ROLLBACK release it through
1459            // the `SnapshotManager` so future snapshots see the correct set of
1460            // active/aborted transactions.
1461            //
1462            // Tuple stamping (xmin/xmax) and read-path visibility filtering
1463            // land in Phase 2.3.2 — this dispatch only manages the snapshot
1464            // registry. Statements running outside a TxnContext still behave
1465            // as autocommit (xid=0 → visible to every snapshot).
1466            QueryExpr::TransactionControl(ref ctl) => {
1467                use crate::storage::transaction::IsolationLevel;
1468                use reddb_rql::ast::TxnControl;
1469
1470                // Phase 2.3 keys transactions by a thread-local connection id.
1471                // The stdio/gRPC paths wire a real per-connection id later;
1472                // for embedded use (one RedDBRuntime per process-ish caller)
1473                // we fall back to a deterministic placeholder.
1474                let conn_id = current_connection_id();
1475
1476                let (kind, msg) = match ctl {
1477                    TxnControl::Begin(requested_isolation) => {
1478                        let store = self.inner.db.store();
1479                        let _publication = store.vault_publication_lock.lock();
1480                        if self.inner.transaction_state.in_transaction(conn_id) {
1481                            return Err(RedDBError::Query("transaction already active".into()));
1482                        }
1483                        let isolation = requested_isolation
1484                            .map(IsolationLevel::from)
1485                            .unwrap_or(IsolationLevel::SnapshotIsolation);
1486                        let xid = self.inner.transaction_state.begin(conn_id, isolation);
1487                        ("begin", format!("BEGIN — xid={xid} (snapshot isolation)"))
1488                    }
1489                    TxnControl::Commit => {
1490                        let has_vault_writes = self
1491                            .inner
1492                            .pending_vault_writes
1493                            .read()
1494                            .get(&conn_id)
1495                            .is_some_and(|writes| !writes.is_empty());
1496                        let store = self.inner.db.store();
1497                        let has_graph_contracts = self
1498                            .db()
1499                            .collection_contracts()
1500                            .iter()
1501                            .any(|contract| contract.graph_contract.is_some());
1502                        let _vault_guard = (has_vault_writes || has_graph_contracts)
1503                            .then(|| store.vault_publication_lock.lock());
1504                        if has_graph_contracts && self.current_xid().is_some() {
1505                            if let Err(error) = self.validate_graph_contracts_at_commit() {
1506                                self.rollback_connection(conn_id)?;
1507                                return Err(error);
1508                            }
1509                        }
1510                        let context = self.inner.transaction_state.commit(conn_id, |ctx| {
1511                            let mut own_xids = std::collections::HashSet::new();
1512                            own_xids.insert(ctx.xid);
1513                            for (_, sub) in &ctx.savepoints {
1514                                own_xids.insert(*sub);
1515                            }
1516                            for sub in &ctx.released_sub_xids {
1517                                own_xids.insert(*sub);
1518                            }
1519                            if let Err(err) = self.check_table_row_write_conflicts(
1520                                conn_id,
1521                                &ctx.snapshot,
1522                                &own_xids,
1523                                ctx.isolation,
1524                            ) {
1525                                let undo = self.revive_pending_versioned_updates(conn_id);
1526                                self.revive_pending_tombstones(conn_id);
1527                                self.discard_pending_kv_watch_events(conn_id);
1528                                self.discard_pending_queue_wakes(conn_id);
1529                                self.discard_pending_store_wal_actions(conn_id);
1530                                self.release_pending_claim_locks(conn_id);
1531                                undo?;
1532                                return Err(err);
1533                            }
1534                            if let Err(err) = self.check_queue_dedup_write_conflicts(
1535                                conn_id,
1536                                &ctx.snapshot,
1537                                &own_xids,
1538                            ) {
1539                                let undo = self.revive_pending_versioned_updates(conn_id);
1540                                self.revive_pending_tombstones(conn_id);
1541                                self.discard_pending_queue_dedup(conn_id);
1542                                self.discard_pending_kv_watch_events(conn_id);
1543                                self.discard_pending_queue_wakes(conn_id);
1544                                self.discard_pending_store_wal_actions(conn_id);
1545                                self.release_pending_claim_locks(conn_id);
1546                                undo?;
1547                                return Err(err);
1548                            }
1549                            if let Err(err) = self.prepare_pending_vault_writes(conn_id, ctx) {
1550                                let undo = self.revive_pending_versioned_updates(conn_id);
1551                                self.revive_pending_tombstones(conn_id);
1552                                self.discard_pending_queue_dedup(conn_id);
1553                                self.discard_pending_kv_watch_events(conn_id);
1554                                self.discard_pending_queue_wakes(conn_id);
1555                                self.discard_pending_store_wal_actions(conn_id);
1556                                self.release_pending_claim_locks(conn_id);
1557                                undo?;
1558                                return Err(err);
1559                            }
1560                            // Validate before taking the publication fence:
1561                            // conflict rollback can acquire index topology.
1562                            // Retain the fence through WAL and visibility so a
1563                            // checkpoint cannot discard a durable commit's WAL.
1564                            let publication = store.vault_publication_lock.lock();
1565                            self.restore_pending_write_stamps(conn_id);
1566                            if let Err(err) = self.flush_pending_store_wal_actions(conn_id) {
1567                                drop(publication);
1568                                let undo = self.revive_pending_versioned_updates(conn_id);
1569                                self.revive_pending_tombstones(conn_id);
1570                                self.discard_pending_queue_dedup(conn_id);
1571                                self.discard_pending_kv_watch_events(conn_id);
1572                                self.discard_pending_queue_wakes(conn_id);
1573                                self.release_pending_claim_locks(conn_id);
1574                                undo?;
1575                                return Err(err);
1576                            }
1577                            #[cfg(test)]
1578                            self.index_store_ref().mutation_test_hook(
1579                                "",
1580                                super::index_store::MutationTestPhase::TransactionWalCommitted,
1581                            );
1582                            Ok(publication)
1583                        });
1584                        if context.is_err() {
1585                            self.discard_pending_vault_writes(conn_id)?;
1586                        }
1587                        let context = context?;
1588                        match context {
1589                            Some(ctx) => {
1590                                self.finalize_pending_vault_writes(conn_id);
1591                                self.finalize_pending_versioned_updates(conn_id);
1592                                self.finalize_pending_tombstones(conn_id);
1593                                self.finalize_pending_queue_dedup(conn_id);
1594                                self.finalize_pending_kv_watch_events(conn_id);
1595                                self.finalize_pending_queue_wakes(conn_id);
1596                                self.release_pending_claim_locks(conn_id);
1597                                self.recover_transactional_replication().map_err(|error| {
1598                                    RedDBError::Internal(format!(
1599                                        "commit is locally durable; replication publication is pending: {error}"
1600                                    ))
1601                                })?;
1602                                ("commit", format!("COMMIT — xid={} committed", ctx.xid))
1603                            }
1604                            None => (
1605                                "commit",
1606                                "COMMIT outside transaction — no-op (autocommit)".to_string(),
1607                            ),
1608                        }
1609                    }
1610                    TxnControl::Rollback => match self.rollback_connection(conn_id)? {
1611                        Some(ctx) => ("rollback", format!("ROLLBACK — xid={} aborted", ctx.xid)),
1612                        None => (
1613                            "rollback",
1614                            "ROLLBACK outside transaction — no-op (autocommit)".to_string(),
1615                        ),
1616                    },
1617                    // Phase 2.3.2e: savepoints map onto sub-xids. Each
1618                    // SAVEPOINT allocates a fresh xid and pushes it
1619                    // onto the per-txn stack so subsequent writes can
1620                    // be selectively rolled back. RELEASE pops without
1621                    // aborting; ROLLBACK TO aborts the sub-xid (and
1622                    // any nested ones) + revives their tombstones.
1623                    TxnControl::Savepoint(name) => {
1624                        match self.inner.transaction_state.savepoint(conn_id, name) {
1625                            Some(sub) => ("savepoint", format!("SAVEPOINT {name} — sub_xid={sub}")),
1626                            None => (
1627                                "savepoint",
1628                                "SAVEPOINT outside transaction — no-op".to_string(),
1629                            ),
1630                        }
1631                    }
1632                    TxnControl::ReleaseSavepoint(name) => {
1633                        match self
1634                            .inner
1635                            .transaction_state
1636                            .release_savepoint(conn_id, name)?
1637                        {
1638                            Some(released) => (
1639                                "release_savepoint",
1640                                format!("RELEASE SAVEPOINT {name} — {released} level(s)"),
1641                            ),
1642                            None => (
1643                                "release_savepoint",
1644                                "RELEASE outside transaction — no-op".to_string(),
1645                            ),
1646                        }
1647                    }
1648                    TxnControl::RollbackToSavepoint(name) => {
1649                        match self
1650                            .inner
1651                            .transaction_state
1652                            .rollback_to_savepoint_with(conn_id, name, |rollback| {
1653                                self.rollback_vault_savepoint(conn_id, rollback.savepoint_xid);
1654                                let reverted_updates = self.revive_versioned_updates_since(
1655                                    conn_id,
1656                                    rollback.savepoint_xid,
1657                                );
1658                                let revived =
1659                                    self.revive_tombstones_since(conn_id, rollback.savepoint_xid);
1660                                reverted_updates.map(|updates| (updates, revived))
1661                            })?
1662                        {
1663                            Some((rollback, (reverted_updates, revived))) => {
1664                                (
1665                                    "rollback_to_savepoint",
1666                                    format!(
1667                                        "ROLLBACK TO SAVEPOINT {name} — aborted {} sub_xid(s), reverted {reverted_updates} update(s), revived {revived} tombstone(s)",
1668                                        rollback.aborted_xids.len(),
1669                                    ),
1670                                )
1671                            }
1672                            None => (
1673                                "rollback_to_savepoint",
1674                                "ROLLBACK TO outside transaction — no-op".to_string(),
1675                            ),
1676                        }
1677                    }
1678                };
1679                Ok(RuntimeQueryResult::ok_message(
1680                    query.to_string(),
1681                    &msg,
1682                    kind,
1683                ))
1684            }
1685            // Schema + Sequence DDL (Phase 1.3 PG parity).
1686            //
1687            // Schemas are lightweight logical namespaces: a CREATE SCHEMA call
1688            // just registers the name in `red.config` under `schema.{name}`.
1689            // Table lookups still happen by collection name; clients using
1690            // `schema.table` qualified names collapse to collection `schema.table`.
1691            //
1692            // Sequences persist a 64-bit counter + metadata (start, increment)
1693            // in `red.config` under `sequence.{name}.*`. Scalar callers
1694            // `nextval('name')` / `currval('name')` arrive with the MVCC phase
1695            // once we have a proper mutating-function dispatch path; for now the
1696            // DDL just establishes the catalog entry so clients don't error.
1697            QueryExpr::CreateSchema(ref q) => {
1698                let store = self.inner.db.store();
1699                let key = format!("schema.{}", q.name);
1700                if store.get_config(&key).is_some() {
1701                    if q.if_not_exists {
1702                        return Ok(RuntimeQueryResult::ok_message(
1703                            query.to_string(),
1704                            &format!("schema {} already exists — skipped", q.name),
1705                            "create_schema",
1706                        ));
1707                    }
1708                    return Err(RedDBError::Internal(format!(
1709                        "schema {} already exists",
1710                        q.name
1711                    )));
1712                }
1713                store.set_config_tree(&key, &crate::serde_json::Value::Bool(true));
1714                Ok(RuntimeQueryResult::ok_message(
1715                    query.to_string(),
1716                    &format!("schema {} created", q.name),
1717                    "create_schema",
1718                ))
1719            }
1720            QueryExpr::DropSchema(ref q) => {
1721                let store = self.inner.db.store();
1722                let key = format!("schema.{}", q.name);
1723                let existed = store.get_config(&key).is_some();
1724                if !existed && !q.if_exists {
1725                    return Err(RedDBError::Internal(format!(
1726                        "schema {} does not exist",
1727                        q.name
1728                    )));
1729                }
1730                // Remove marker from red.config via set to null.
1731                store.set_config_tree(&key, &crate::serde_json::Value::Null);
1732                let suffix = if q.cascade {
1733                    " (CASCADE accepted — tables untouched)"
1734                } else {
1735                    ""
1736                };
1737                Ok(RuntimeQueryResult::ok_message(
1738                    query.to_string(),
1739                    &format!("schema {} dropped{}", q.name, suffix),
1740                    "drop_schema",
1741                ))
1742            }
1743            QueryExpr::CreateSequence(ref q) => {
1744                let store = self.inner.db.store();
1745                let base = format!("sequence.{}", q.name);
1746                let start_key = format!("{base}.start");
1747                let incr_key = format!("{base}.increment");
1748                let curr_key = format!("{base}.current");
1749                if store.get_config(&start_key).is_some() {
1750                    if q.if_not_exists {
1751                        return Ok(RuntimeQueryResult::ok_message(
1752                            query.to_string(),
1753                            &format!("sequence {} already exists — skipped", q.name),
1754                            "create_sequence",
1755                        ));
1756                    }
1757                    return Err(RedDBError::Internal(format!(
1758                        "sequence {} already exists",
1759                        q.name
1760                    )));
1761                }
1762                // Persist start + increment, and set current so the first
1763                // nextval returns `start`.
1764                let initial_current = q.start - q.increment;
1765                store.set_config_tree(
1766                    &start_key,
1767                    &crate::serde_json::Value::Number(q.start as f64),
1768                );
1769                store.set_config_tree(
1770                    &incr_key,
1771                    &crate::serde_json::Value::Number(q.increment as f64),
1772                );
1773                store.set_config_tree(
1774                    &curr_key,
1775                    &crate::serde_json::Value::Number(initial_current as f64),
1776                );
1777                Ok(RuntimeQueryResult::ok_message(
1778                    query.to_string(),
1779                    &format!(
1780                        "sequence {} created (start={}, increment={})",
1781                        q.name, q.start, q.increment
1782                    ),
1783                    "create_sequence",
1784                ))
1785            }
1786            QueryExpr::DropSequence(ref q) => {
1787                let store = self.inner.db.store();
1788                let base = format!("sequence.{}", q.name);
1789                let existed = store.get_config(&format!("{base}.start")).is_some();
1790                if !existed && !q.if_exists {
1791                    return Err(RedDBError::Internal(format!(
1792                        "sequence {} does not exist",
1793                        q.name
1794                    )));
1795                }
1796                for k in ["start", "increment", "current"] {
1797                    store.set_config_tree(&format!("{base}.{k}"), &crate::serde_json::Value::Null);
1798                }
1799                Ok(RuntimeQueryResult::ok_message(
1800                    query.to_string(),
1801                    &format!("sequence {} dropped", q.name),
1802                    "drop_sequence",
1803                ))
1804            }
1805            // Views — CREATE [MATERIALIZED] VIEW (Phase 2.1 PG parity).
1806            //
1807            // The view definition is stored in-memory on RuntimeInner (not
1808            // persisted). SELECTs that reference the view name will substitute
1809            // the stored `QueryExpr` via `resolve_view_reference` during
1810            // planning (same entry point used by table-name resolution).
1811            //
1812            // Materialized views additionally allocate a slot in
1813            // `MaterializedViewCache`; a REFRESH repopulates that slot.
1814            QueryExpr::CreateView(ref q) => {
1815                let mut views = self.inner.views.write();
1816                if views.contains_key(&q.name) && !q.or_replace {
1817                    if q.if_not_exists {
1818                        return Ok(RuntimeQueryResult::ok_message(
1819                            query.to_string(),
1820                            &format!("view {} already exists — skipped", q.name),
1821                            "create_view",
1822                        ));
1823                    }
1824                    return Err(RedDBError::Internal(format!(
1825                        "view {} already exists",
1826                        q.name
1827                    )));
1828                }
1829                views.insert(q.name.clone(), Arc::new(q.clone()));
1830                drop(views);
1831
1832                // Materialized view: register cache slot (data is empty until REFRESH).
1833                if q.materialized {
1834                    use crate::storage::cache::result::{MaterializedViewDef, RefreshPolicy};
1835                    let refresh = match q.refresh_every_ms {
1836                        Some(ms) => RefreshPolicy::Periodic(std::time::Duration::from_millis(ms)),
1837                        None => RefreshPolicy::Manual,
1838                    };
1839                    let dependencies = collect_table_refs(&q.query);
1840                    let def = MaterializedViewDef {
1841                        name: q.name.clone(),
1842                        query: format!("<parsed view {}>", q.name),
1843                        dependencies: dependencies.clone(),
1844                        refresh,
1845                        retention_duration_ms: q.retention_duration_ms,
1846                    };
1847                    self.inner.materialized_views.write().register(def);
1848
1849                    // Issue #593 slice 9a — persist the descriptor to
1850                    // the system catalog so the definition survives a
1851                    // restart. Upsert semantics (delete-then-insert by
1852                    // name) keep the catalog free of duplicate rows
1853                    // across `CREATE OR REPLACE` churn.
1854                    let descriptor =
1855                        crate::runtime::continuous_materialized_view::MaterializedViewDescriptor {
1856                            name: q.name.clone(),
1857                            source_sql: query.to_string(),
1858                            source_collections: dependencies,
1859                            refresh_every_ms: q.refresh_every_ms,
1860                            retention_duration_ms: q.retention_duration_ms,
1861                        };
1862                    let store = self.inner.db.store();
1863                    crate::runtime::continuous_materialized_view::persist_descriptor(
1864                        store.as_ref(),
1865                        &descriptor,
1866                    )?;
1867
1868                    // Issue #594 slice 9b — provision a Table-shaped
1869                    // backing collection named after the view. The
1870                    // rewriter skips materialized views (see
1871                    // `rewrite_view_refs_inner`) so `SELECT FROM v`
1872                    // resolves to this collection directly. Empty
1873                    // until REFRESH wires through it in 9c.
1874                    self.ensure_materialized_view_backing(&q.name)?;
1875                }
1876                // Plan cache may have cached a plan that didn't know about this
1877                // view — invalidate so future references pick up the new binding.
1878                // Result cache gets flushed too: OR REPLACE must not serve a
1879                // prior execution of the obsolete body.
1880                self.invalidate_plan_cache();
1881                self.invalidate_result_cache();
1882
1883                Ok(RuntimeQueryResult::ok_message(
1884                    query.to_string(),
1885                    &format!(
1886                        "{}view {} created",
1887                        if q.materialized { "materialized " } else { "" },
1888                        q.name
1889                    ),
1890                    "create_view",
1891                ))
1892            }
1893            QueryExpr::DropView(ref q) => {
1894                let mut views = self.inner.views.write();
1895                let removed = views.remove(&q.name);
1896                let existed = removed.is_some();
1897                let removed_materialized =
1898                    removed.as_ref().map(|v| v.materialized).unwrap_or(false);
1899                drop(views);
1900                if q.materialized || existed {
1901                    // Try the materialised cache too — silent if absent.
1902                    self.inner.materialized_views.write().remove(&q.name);
1903                    // Issue #593 slice 9a — remove any persisted
1904                    // catalog row. Idempotent: a no-op when the view
1905                    // was never materialized (no row was ever written).
1906                    let store = self.inner.db.store();
1907                    crate::runtime::continuous_materialized_view::remove_by_name(
1908                        store.as_ref(),
1909                        &q.name,
1910                    )?;
1911                }
1912                // Issue #594 slice 9b — drop the backing collection
1913                // that was provisioned at CREATE time. Only mat views
1914                // ever had one; regular views never did.
1915                if removed_materialized || q.materialized {
1916                    self.drop_materialized_view_backing(&q.name)?;
1917                }
1918                // Drop any plan / result cache entries that baked the
1919                // view body into their QueryExpr.
1920                self.invalidate_plan_cache();
1921                self.invalidate_result_cache();
1922                if !existed && !q.if_exists {
1923                    return Err(RedDBError::Internal(format!(
1924                        "view {} does not exist",
1925                        q.name
1926                    )));
1927                }
1928                self.invalidate_plan_cache();
1929                Ok(RuntimeQueryResult::ok_message(
1930                    query.to_string(),
1931                    &format!("view {} dropped", q.name),
1932                    "drop_view",
1933                ))
1934            }
1935            QueryExpr::RefreshMaterializedView(ref q) => {
1936                // Look up the view definition, execute its underlying query,
1937                // and stash the serialized result in the materialised cache.
1938                let view = {
1939                    let views = self.inner.views.read();
1940                    views.get(&q.name).cloned()
1941                };
1942                let view = match view {
1943                    Some(v) => v,
1944                    None => {
1945                        return Err(RedDBError::Internal(format!(
1946                            "view {} does not exist",
1947                            q.name
1948                        )))
1949                    }
1950                };
1951                if !view.materialized {
1952                    return Err(RedDBError::Internal(format!(
1953                        "view {} is not materialized — REFRESH requires \
1954                         CREATE MATERIALIZED VIEW",
1955                        q.name
1956                    )));
1957                }
1958                // Execute the underlying query fresh.
1959                let started = std::time::Instant::now();
1960                let now_ms = std::time::SystemTime::now()
1961                    .duration_since(std::time::UNIX_EPOCH)
1962                    .map(|d| d.as_millis() as u64)
1963                    .unwrap_or(0);
1964                match self.execute_query_expr((*view.query).clone()) {
1965                    Ok(inner_result) => {
1966                        // Issue #595 slice 9c — atomically replace the
1967                        // backing collection's contents under a single
1968                        // WAL group. Concurrent SELECT from the view
1969                        // sees either the prior or new contents, never
1970                        // partial. A crash before the WAL commit lands
1971                        // leaves the prior contents intact on recovery.
1972                        let entities =
1973                            view_records_to_entities(&q.name, &inner_result.result.records);
1974                        let row_count = entities.len() as u64;
1975                        let store = self.inner.db.store();
1976                        let serialized_records = match store.refresh_collection(&q.name, entities) {
1977                            Ok(records) => records,
1978                            Err(err) => {
1979                                let duration_ms = started.elapsed().as_millis() as u64;
1980                                let msg = err.to_string();
1981                                self.inner
1982                                    .materialized_views
1983                                    .write()
1984                                    .record_refresh_failure(
1985                                        &q.name,
1986                                        msg.clone(),
1987                                        duration_ms,
1988                                        now_ms,
1989                                    );
1990                                return Err(RedDBError::Internal(format!(
1991                                    "REFRESH MATERIALIZED VIEW {}: {msg}",
1992                                    q.name
1993                                )));
1994                            }
1995                        };
1996
1997                        // Issue #596 slice 9d — emit a Refresh
1998                        // ChangeRecord into the logical-WAL spool so
1999                        // replicas deterministically replay the same
2000                        // backing-collection contents via
2001                        // `LogicalChangeApplier::apply_record`.
2002                        if self.inner.db.replication.is_some() {
2003                            let lsn = self.inner.cdc.emit(
2004                                crate::replication::cdc::ChangeOperation::Refresh,
2005                                &q.name,
2006                                0,
2007                                "refresh",
2008                            );
2009                            self.invalidate_result_cache_for_table(&q.name);
2010                            let timestamp = std::time::SystemTime::now()
2011                                .duration_since(std::time::UNIX_EPOCH)
2012                                .unwrap_or_default()
2013                                .as_millis() as u64;
2014                            let record = ChangeRecord::for_refresh(
2015                                lsn,
2016                                timestamp,
2017                                q.name.clone(),
2018                                serialized_records,
2019                            )
2020                            .with_term(self.current_replication_term());
2021                            self.stage_or_publish_logical_record(record)?;
2022                        }
2023
2024                        let duration_ms = started.elapsed().as_millis() as u64;
2025                        let serialized = format!("{:?}", inner_result.result);
2026                        self.inner
2027                            .materialized_views
2028                            .write()
2029                            .record_refresh_success(
2030                                &q.name,
2031                                serialized.into_bytes(),
2032                                row_count,
2033                                duration_ms,
2034                                now_ms,
2035                            );
2036                        // SELECT FROM v now reads through the rewriter
2037                        // skip into the backing collection — drop the
2038                        // result cache so prior empty-backing reads
2039                        // don't shadow the new contents.
2040                        self.invalidate_result_cache();
2041                        Ok(RuntimeQueryResult::ok_message(
2042                            query.to_string(),
2043                            &format!("materialized view {} refreshed", q.name),
2044                            "refresh_materialized_view",
2045                        ))
2046                    }
2047                    Err(err) => {
2048                        let duration_ms = started.elapsed().as_millis() as u64;
2049                        let msg = err.to_string();
2050                        self.inner
2051                            .materialized_views
2052                            .write()
2053                            .record_refresh_failure(&q.name, msg.clone(), duration_ms, now_ms);
2054                        Err(err)
2055                    }
2056                }
2057            }
2058            // Row Level Security (Phase 2.5 PG parity).
2059            //
2060            // Policies live in an in-memory registry keyed by (table, name).
2061            // Enforcement (AND-ing the policy's USING clause into every
2062            // query's WHERE for the table) arrives in Phase 2.5.2 via the
2063            // filter compiler; this dispatch only manages the catalog.
2064            QueryExpr::CreatePolicy(ref q) => {
2065                let key = (q.table.clone(), q.name.clone());
2066                self.inner
2067                    .rls_policies
2068                    .write()
2069                    .insert(key, Arc::new(q.clone()));
2070                self.invalidate_plan_cache();
2071                self.invalidate_result_cache();
2072                // Issue #120 — surface policy names in the
2073                // schema-vocabulary so AskPipeline (#121) can resolve
2074                // a policy reference back to its table.
2075                self.schema_vocabulary_apply(
2076                    crate::runtime::schema_vocabulary::DdlEvent::CreatePolicy {
2077                        collection: q.table.clone(),
2078                        policy: q.name.clone(),
2079                    },
2080                );
2081                Ok(RuntimeQueryResult::ok_message(
2082                    query.to_string(),
2083                    &format!("policy {} on {} created", q.name, q.table),
2084                    "create_policy",
2085                ))
2086            }
2087            QueryExpr::DropPolicy(ref q) => {
2088                let removed = self
2089                    .inner
2090                    .rls_policies
2091                    .write()
2092                    .remove(&(q.table.clone(), q.name.clone()))
2093                    .is_some();
2094                if !removed && !q.if_exists {
2095                    return Err(RedDBError::Internal(format!(
2096                        "policy {} on {} does not exist",
2097                        q.name, q.table
2098                    )));
2099                }
2100                self.invalidate_plan_cache();
2101                self.invalidate_result_cache();
2102                // Issue #120 — keep the schema-vocabulary policy
2103                // entry in sync.
2104                self.schema_vocabulary_apply(
2105                    crate::runtime::schema_vocabulary::DdlEvent::DropPolicy {
2106                        collection: q.table.clone(),
2107                        policy: q.name.clone(),
2108                    },
2109                );
2110                Ok(RuntimeQueryResult::ok_message(
2111                    query.to_string(),
2112                    &format!("policy {} on {} dropped", q.name, q.table),
2113                    "drop_policy",
2114                ))
2115            }
2116            // Foreign Data Wrappers (Phase 3.2 PG parity).
2117            //
2118            // CREATE SERVER / CREATE FOREIGN TABLE register into the shared
2119            // `ForeignTableRegistry`. The read path consults that registry
2120            // before dispatching a SELECT — when the table name matches a
2121            // registered foreign table, we forward the scan to the wrapper
2122            // and skip the normal collection lookup.
2123            //
2124            // Phase 3.2 is in-memory only; persistence across restarts is a
2125            // 3.2.2 follow-up that mirrors the view registry pattern.
2126            QueryExpr::CreateServer(ref q) => {
2127                use crate::storage::fdw::FdwOptions;
2128                let registry = Arc::clone(&self.inner.foreign_tables);
2129                if registry.server(&q.name).is_some() {
2130                    if q.if_not_exists {
2131                        return Ok(RuntimeQueryResult::ok_message(
2132                            query.to_string(),
2133                            &format!("server {} already exists — skipped", q.name),
2134                            "create_server",
2135                        ));
2136                    }
2137                    return Err(RedDBError::Internal(format!(
2138                        "server {} already exists",
2139                        q.name
2140                    )));
2141                }
2142                let mut opts = FdwOptions::new();
2143                for (k, v) in &q.options {
2144                    opts.values.insert(k.clone(), v.clone());
2145                }
2146                registry
2147                    .create_server(&q.name, &q.wrapper, opts)
2148                    .map_err(|e| RedDBError::Internal(e.to_string()))?;
2149                Ok(RuntimeQueryResult::ok_message(
2150                    query.to_string(),
2151                    &format!("server {} created (wrapper {})", q.name, q.wrapper),
2152                    "create_server",
2153                ))
2154            }
2155            QueryExpr::DropServer(ref q) => {
2156                let existed = self.inner.foreign_tables.drop_server(&q.name);
2157                if !existed && !q.if_exists {
2158                    return Err(RedDBError::Internal(format!(
2159                        "server {} does not exist",
2160                        q.name
2161                    )));
2162                }
2163                Ok(RuntimeQueryResult::ok_message(
2164                    query.to_string(),
2165                    &format!(
2166                        "server {} dropped{}",
2167                        q.name,
2168                        if q.cascade { " (cascade)" } else { "" }
2169                    ),
2170                    "drop_server",
2171                ))
2172            }
2173            QueryExpr::CreateForeignTable(ref q) => {
2174                use crate::storage::fdw::{FdwOptions, ForeignColumn, ForeignTable};
2175                let registry = Arc::clone(&self.inner.foreign_tables);
2176                if registry.foreign_table(&q.name).is_some() {
2177                    if q.if_not_exists {
2178                        return Ok(RuntimeQueryResult::ok_message(
2179                            query.to_string(),
2180                            &format!("foreign table {} already exists — skipped", q.name),
2181                            "create_foreign_table",
2182                        ));
2183                    }
2184                    return Err(RedDBError::Internal(format!(
2185                        "foreign table {} already exists",
2186                        q.name
2187                    )));
2188                }
2189                let mut opts = FdwOptions::new();
2190                for (k, v) in &q.options {
2191                    opts.values.insert(k.clone(), v.clone());
2192                }
2193                let columns: Vec<ForeignColumn> = q
2194                    .columns
2195                    .iter()
2196                    .map(|c| ForeignColumn {
2197                        name: c.name.clone(),
2198                        data_type: c.data_type.clone(),
2199                        not_null: c.not_null,
2200                    })
2201                    .collect();
2202                registry
2203                    .create_foreign_table(ForeignTable {
2204                        name: q.name.clone(),
2205                        server_name: q.server.clone(),
2206                        columns,
2207                        options: opts,
2208                    })
2209                    .map_err(|e| RedDBError::Internal(e.to_string()))?;
2210                self.invalidate_plan_cache();
2211                Ok(RuntimeQueryResult::ok_message(
2212                    query.to_string(),
2213                    &format!("foreign table {} created (server {})", q.name, q.server),
2214                    "create_foreign_table",
2215                ))
2216            }
2217            QueryExpr::DropForeignTable(ref q) => {
2218                let existed = self.inner.foreign_tables.drop_foreign_table(&q.name);
2219                if !existed && !q.if_exists {
2220                    return Err(RedDBError::Internal(format!(
2221                        "foreign table {} does not exist",
2222                        q.name
2223                    )));
2224                }
2225                self.invalidate_plan_cache();
2226                Ok(RuntimeQueryResult::ok_message(
2227                    query.to_string(),
2228                    &format!("foreign table {} dropped", q.name),
2229                    "drop_foreign_table",
2230                ))
2231            }
2232            // COPY table FROM 'path' (Phase 1.5 PG parity).
2233            //
2234            // Stream CSV rows through the shared `CsvImporter`. The collection
2235            // is auto-created on first insert (via `insert_auto`-style path);
2236            // VACUUM/ANALYZE afterwards is up to the caller.
2237            QueryExpr::CopyFrom(ref q) => {
2238                use crate::storage::import::{CsvConfig, CsvImporter};
2239                self.check_copy_from_privilege(&q.table)?;
2240                confine_copy_from_path(&q.path)?;
2241                let store = self.inner.db.store();
2242                let cfg = CsvConfig {
2243                    collection: q.table.clone(),
2244                    has_header: q.has_header,
2245                    delimiter: q.delimiter.map(|c| c as u8).unwrap_or(b','),
2246                    ..CsvConfig::default()
2247                };
2248                let importer = CsvImporter::new(cfg);
2249                let stats = importer
2250                    .import_file(&q.path, store.as_ref())
2251                    .map_err(|e| RedDBError::Internal(format!("COPY failed: {e}")))?;
2252                // Tables are written → invalidate cached plans / result cache.
2253                self.note_table_write(&q.table);
2254                Ok(RuntimeQueryResult::ok_message(
2255                    query.to_string(),
2256                    &format!(
2257                        "COPY imported {} rows into {} ({} errors skipped, {}ms)",
2258                        stats.records_imported, q.table, stats.errors_skipped, stats.duration_ms
2259                    ),
2260                    "copy_from",
2261                ))
2262            }
2263            // Maintenance commands (Phase 1.2 PG parity).
2264            //
2265            // - VACUUM [FULL] [table]: refreshes planner stats for the target
2266            //   collection(s) and — when FULL — triggers a full pager persist
2267            //   (flushes dirty pages + fsync). Also invalidates the result cache
2268            //   so subsequent reads re-execute against the freshly compacted
2269            //   storage. RedDB's segment/btree GC runs continuously via the
2270            //   background lifecycle; explicit space reclamation for sealed
2271            //   segments arrives with Phase 2.3 (MVCC + dead-tuple reclamation).
2272            // - ANALYZE [table]: reruns `analyze_collection` +
2273            //   `persist_table_stats` via `refresh_table_planner_stats` so the
2274            //   planner has fresh histograms, distinct estimates, null counts.
2275            //
2276            // Both commands accept an optional target; omitting the target
2277            // iterates every collection in the store.
2278            QueryExpr::MaintenanceCommand(ref cmd) => {
2279                use reddb_rql::ast::MaintenanceCommand as Mc;
2280                let store = self.inner.db.store();
2281                let (kind, msg) = match cmd {
2282                    Mc::Analyze { target } => {
2283                        let targets: Vec<String> = match target {
2284                            Some(t) => vec![t.clone()],
2285                            None => store.list_collections(),
2286                        };
2287                        for t in &targets {
2288                            self.refresh_table_planner_stats(t);
2289                        }
2290                        (
2291                            "analyze",
2292                            format!("ANALYZE refreshed stats for {} table(s)", targets.len()),
2293                        )
2294                    }
2295                    Mc::Vacuum { target, full } => {
2296                        let targets: Vec<String> = match target {
2297                            Some(t) => vec![t.clone()],
2298                            None => store.list_collections(),
2299                        };
2300                        let cutoff_xid = self.mvcc_vacuum_cutoff_xid();
2301                        let mut vacuum_stats =
2302                            crate::storage::unified::store::MvccVacuumStats::default();
2303                        for t in &targets {
2304                            let stats = store.vacuum_mvcc_history(t, cutoff_xid).map_err(|e| {
2305                                RedDBError::Internal(format!(
2306                                    "VACUUM MVCC history failed for {t}: {e}"
2307                                ))
2308                            })?;
2309                            if stats.reclaimed_versions > 0 {
2310                                self.rebuild_runtime_indexes_for_table(t)?;
2311                            }
2312                            vacuum_stats.add(&stats);
2313                        }
2314                        // The cutoff protects snapshots, not physical references.
2315                        // Aborted inserts can remain in this or another collection;
2316                        // forgetting their outcome would make them visible again.
2317                        // Stats refresh covers every target (same as ANALYZE).
2318                        for t in &targets {
2319                            self.refresh_table_planner_stats(t);
2320                        }
2321                        // FULL forces a pager persist (dirty-page flush + fsync).
2322                        // Regular VACUUM relies on the background writer / segment
2323                        // lifecycle so the command is non-blocking.
2324                        let persisted = if *full {
2325                            match store.persist() {
2326                                Ok(()) => true,
2327                                Err(e) => {
2328                                    return Err(RedDBError::Internal(format!(
2329                                        "VACUUM FULL persist failed: {e:?}"
2330                                    )));
2331                                }
2332                            }
2333                        } else {
2334                            false
2335                        };
2336                        // Result cache depended on pre-vacuum state.
2337                        self.invalidate_result_cache();
2338                        (
2339                            "vacuum",
2340                            format!(
2341                                "VACUUM{} processed {} table(s): scanned_versions={}, retained_versions={}, reclaimed_versions={}, retained_history_versions={}, reclaimed_history_versions={}, retained_tombstones={}, reclaimed_tombstones={}{}",
2342                                if *full { " FULL" } else { "" },
2343                                targets.len(),
2344                                vacuum_stats.scanned_versions,
2345                                vacuum_stats.retained_versions,
2346                                vacuum_stats.reclaimed_versions,
2347                                vacuum_stats.retained_history_versions,
2348                                vacuum_stats.reclaimed_history_versions,
2349                                vacuum_stats.retained_tombstones,
2350                                vacuum_stats.reclaimed_tombstones,
2351                                if persisted {
2352                                    " (pages flushed to disk)"
2353                                } else {
2354                                    ""
2355                                }
2356                            ),
2357                        )
2358                    }
2359                };
2360                Ok(RuntimeQueryResult::ok_message(
2361                    query.to_string(),
2362                    &msg,
2363                    kind,
2364                ))
2365            }
2366            // GRANT / REVOKE / ALTER USER (RBAC milestone).
2367            //
2368            // These hit the AuthStore directly. The statement frame /
2369            // privilege gate has already decided whether the caller may
2370            // even run the statement; here we just translate the AST into
2371            // AuthStore calls.
2372            QueryExpr::Grant(ref g) => self.execute_grant_statement(query, g),
2373            QueryExpr::Revoke(ref r) => self.execute_revoke_statement(query, r),
2374            QueryExpr::AlterUser(ref a) => self.execute_alter_user_statement(query, a),
2375            QueryExpr::CreateUser(ref u) => self.execute_create_user_statement(query, u),
2376            QueryExpr::CreateIamPolicy { ref id, ref json } => {
2377                self.execute_create_iam_policy(query, id, json)
2378            }
2379            QueryExpr::DropIamPolicy { ref id } => self.execute_drop_iam_policy(query, id),
2380            QueryExpr::AttachPolicy {
2381                ref policy_id,
2382                ref principal,
2383            } => self.execute_attach_policy(query, policy_id, principal),
2384            QueryExpr::DetachPolicy {
2385                ref policy_id,
2386                ref principal,
2387            } => self.execute_detach_policy(query, policy_id, principal),
2388            QueryExpr::ShowPolicies { ref filter } => {
2389                self.execute_show_policies(query, filter.as_ref())
2390            }
2391            QueryExpr::ShowEffectivePermissions {
2392                ref user,
2393                ref resource,
2394            } => self.execute_show_effective_permissions(query, user, resource.as_ref()),
2395            QueryExpr::SimulatePolicy {
2396                ref user,
2397                ref action,
2398                ref resource,
2399            } => self.execute_simulate_policy(query, user, action, resource),
2400            QueryExpr::LintPolicy { ref source } => self.execute_lint_policy(query, source),
2401            QueryExpr::MigratePolicyMode {
2402                ref target,
2403                dry_run,
2404            } => self.execute_migrate_policy_mode(query, target, dry_run),
2405            QueryExpr::CreateMigration(ref q) => self.execute_create_migration(query, q),
2406            QueryExpr::ApplyMigration(ref q) => self.execute_apply_migration(query, q),
2407            QueryExpr::RollbackMigration(ref q) => self.execute_rollback_migration(query, q),
2408            QueryExpr::ExplainMigration(ref q) => self.execute_explain_migration(query, q),
2409            _ => Err(RedDBError::Query(
2410                "unsupported command in runtime dispatcher".to_string(),
2411            )),
2412        };
2413
2414        if _query_budget.is_some() {
2415            super::query_budget::charge(0)?;
2416        }
2417        if !control_event_specs.is_empty() {
2418            let (outcome, reason) = match &query_result {
2419                Ok(_) => (crate::runtime::control_events::Outcome::Allowed, None),
2420                Err(err) => (control_event_outcome_for_error(err), Some(err.to_string())),
2421            };
2422            for spec in &control_event_specs {
2423                self.emit_control_event(
2424                    spec.kind,
2425                    outcome,
2426                    spec.action,
2427                    spec.resource.clone(),
2428                    reason.clone(),
2429                    spec.fields.clone(),
2430                )?;
2431            }
2432        }
2433
2434        if let (Some(plan), Ok(result)) = (&query_audit_plan, &query_result) {
2435            self.emit_query_audit(
2436                query,
2437                plan,
2438                query_audit_started.elapsed().as_millis() as u64,
2439                result,
2440            );
2441        }
2442
2443        // Cache SELECT results for 30s.
2444        // Skip: pre-serialized JSON (large clone), and result sets > 5 rows.
2445        // Large multi-row results (range scans, filtered scans) are rarely
2446        // repeated with the same literal values so the cache hit rate is near
2447        // zero while the clone cost (100 records × ~16 fields each) is high.
2448        // Aggregations (1 row) and point lookups (1 row) still benefit.
2449        if let Ok(ref result) = query_result {
2450            frame.write_result_cache(self, result, result_cache_scopes);
2451        }
2452
2453        query_result
2454    }
2455
2456    /// Execute a pre-parsed `QueryExpr` that has no accompanying SQL text,
2457    /// bypassing SQL parsing and the plan cache.
2458    ///
2459    /// Prefer [`Self::execute_prepared_query`] or
2460    /// [`Self::execute_query_with_params`]: they carry the statement text, so
2461    /// the frame's snapshot (including `AS OF`), coarse privilege class, lock
2462    /// intent, and result-cache key all describe the statement that actually
2463    /// runs. This entry exists for the nested in-statement callers — the CTE
2464    /// prelude, materialized-view refresh, and the inline graph TVF — which
2465    /// hold an expression the outer statement already parsed.
2466    ///
2467    /// Cost: this is no longer a "zero overhead" path. It builds and installs
2468    /// a full `StatementExecutionFrame` (tenant / config / secret / KV guards
2469    /// plus an MVCC snapshot), which is what buys it the same snapshot
2470    /// isolation as textual SQL. Nested calls inherit the enclosing
2471    /// statement's snapshot rather than minting a new one, and the frame opts
2472    /// out of the result cache because there is no stable text key.
2473    ///
2474    /// Keeps secret SELECT results masked, identical to `execute_query`.
2475    pub fn execute_query_expr(&self, expr: QueryExpr) -> RedDBResult<RuntimeQueryResult> {
2476        let frame = super::statement_frame::StatementExecutionFrame::build(
2477            self,
2478            super::statement_frame::StatementIdentity::Expr(&expr),
2479        )?;
2480        let _frame_guards = Box::new(frame.install(self));
2481        // View rewrite (Phase 2.1): substitute any `QueryExpr::Table(tq)`
2482        // whose `tq.table` matches a registered view with the view's
2483        // underlying query. Safe to call even when no views are registered.
2484        let expr = self.rewrite_view_refs(expr);
2485
2486        self.validate_model_operations_before_auth(&expr)?;
2487        // Granular RBAC privilege check. Runs before dispatch so a
2488        // denied caller never reaches storage. Fail-closed: any error
2489        // resolving the action / resource produces PermissionDenied.
2490        if let Err(err) = self.check_query_privilege(&expr) {
2491            return Err(RedDBError::Query(format!("permission denied: {err}")));
2492        }
2493
2494        frame.release_snapshot_for_maintenance(&expr);
2495        let statement = query_expr_name(&expr);
2496        let mode = detect_mode(statement);
2497        let query_str = statement;
2498
2499        self.dispatch_expr(expr, query_str, mode)
2500    }
2501
2502    pub(super) fn validate_model_operations_before_auth(
2503        &self,
2504        expr: &QueryExpr,
2505    ) -> RedDBResult<()> {
2506        self.validate_vault_table_access(expr)?;
2507        use crate::catalog::CollectionModel;
2508        use crate::runtime::ddl::polymorphic_resolver;
2509        use reddb_rql::ast::KvCommand;
2510
2511        let system_schema_target = match expr {
2512            QueryExpr::DropTable(q) => Some(q.name.as_str()),
2513            QueryExpr::DropGraph(q) => Some(q.name.as_str()),
2514            QueryExpr::DropVector(q) => Some(q.name.as_str()),
2515            QueryExpr::DropDocument(q) => Some(q.name.as_str()),
2516            QueryExpr::DropKv(q) => Some(q.name.as_str()),
2517            QueryExpr::DropCollection(q) => Some(q.name.as_str()),
2518            QueryExpr::Truncate(q) => Some(q.name.as_str()),
2519            _ => None,
2520        };
2521        if system_schema_target.is_some_and(crate::runtime::impl_ddl::is_system_schema_name) {
2522            return Err(RedDBError::Query("system schema is read-only".to_string()));
2523        }
2524
2525        let expected = match expr {
2526            QueryExpr::DropTable(q) => Some((q.name.as_str(), CollectionModel::Table)),
2527            QueryExpr::DropGraph(q) => Some((q.name.as_str(), CollectionModel::Graph)),
2528            QueryExpr::DropVector(q) => Some((q.name.as_str(), CollectionModel::Vector)),
2529            QueryExpr::DropDocument(q) => Some((q.name.as_str(), CollectionModel::Document)),
2530            QueryExpr::DropKv(q) => Some((q.name.as_str(), q.model)),
2531            QueryExpr::DropCollection(q) => q.model.map(|model| (q.name.as_str(), model)),
2532            QueryExpr::Truncate(q) => q.model.map(|model| (q.name.as_str(), model)),
2533            QueryExpr::KvCommand(cmd) => {
2534                let (collection, model) = match cmd {
2535                    KvCommand::Put {
2536                        collection, model, ..
2537                    }
2538                    | KvCommand::Get {
2539                        collection, model, ..
2540                    }
2541                    | KvCommand::Incr {
2542                        collection, model, ..
2543                    }
2544                    | KvCommand::Cas {
2545                        collection, model, ..
2546                    }
2547                    | KvCommand::List {
2548                        collection, model, ..
2549                    }
2550                    | KvCommand::Delete {
2551                        collection, model, ..
2552                    } => (collection.as_str(), *model),
2553                    KvCommand::Rotate { collection, .. }
2554                    | KvCommand::History { collection, .. }
2555                    | KvCommand::Purge { collection, .. } => {
2556                        (collection.as_str(), CollectionModel::Vault)
2557                    }
2558                    KvCommand::InvalidateTags { collection, .. } => {
2559                        (collection.as_str(), CollectionModel::Kv)
2560                    }
2561                    KvCommand::Watch {
2562                        collection, model, ..
2563                    } => (collection.as_str(), *model),
2564                    KvCommand::Reveal { collection, .. } => {
2565                        (collection.as_str(), CollectionModel::Vault)
2566                    }
2567                };
2568                Some((collection, model))
2569            }
2570            QueryExpr::ConfigCommand(cmd) => {
2571                self.validate_config_command_before_auth(cmd)?;
2572                None
2573            }
2574            _ => None,
2575        };
2576
2577        let Some((name, expected_model)) = expected else {
2578            return Ok(());
2579        };
2580        // Declared models are authoritative in the catalog snapshot. Resolve
2581        // just this contract instead of observing every row in every collection
2582        // for a point command. Keep the snapshot fallback for undeclared legacy
2583        // collections and orphaned contracts, preserving their inference rules.
2584        if let Some(contract) = self.inner.db.collection_contract_arc(name) {
2585            if self.inner.db.store().get_collection(name).is_some() {
2586                return polymorphic_resolver::ensure_model_match(
2587                    expected_model,
2588                    contract.declared_model,
2589                );
2590            }
2591        }
2592        let snapshot = self.inner.db.catalog_model_snapshot();
2593        let Some(actual_model) = snapshot
2594            .collections
2595            .iter()
2596            .find(|collection| collection.name == name)
2597            .map(|collection| collection.declared_model.unwrap_or(collection.model))
2598        else {
2599            return Ok(());
2600        };
2601        polymorphic_resolver::ensure_model_match(expected_model, actual_model)
2602    }
2603
2604    /// Walk a `QueryExpr` and replace `QueryExpr::Table(tq)` nodes whose
2605    /// `tq.table` matches a registered view name with the view's stored
2606    /// body. Recurses through joins so `SELECT ... FROM t JOIN myview ...`
2607    /// resolves correctly. Pure operation — no side effects.
2608    pub(super) fn rewrite_view_refs(&self, expr: QueryExpr) -> QueryExpr {
2609        // Fast path: no views registered → return original expression.
2610        if self.inner.views.read().is_empty() {
2611            return expr;
2612        }
2613        self.rewrite_view_refs_inner(expr)
2614    }
2615
2616    fn rewrite_view_refs_inner(&self, expr: QueryExpr) -> QueryExpr {
2617        use reddb_rql::ast::{Filter, TableSource};
2618        match expr {
2619            QueryExpr::Table(mut tq) => {
2620                // 1. If the TableSource is a subquery, recurse into it so
2621                //    `SELECT ... FROM (SELECT ... FROM myview) t` expands.
2622                //    The legacy `table` field (set to a synthetic
2623                //    "__subq_NNNN" sentinel) stays as-is so callers that
2624                //    read it keep compiling.
2625                if let Some(TableSource::Subquery(body)) = tq.source.take() {
2626                    tq.source = Some(TableSource::Subquery(Box::new(
2627                        self.rewrite_view_refs_inner(*body),
2628                    )));
2629                    return QueryExpr::Table(tq);
2630                }
2631
2632                // 2. Restore the source field (took it above for match).
2633                // When the source was `None` or `TableSource::Name(_)`, the
2634                // real lookup key is `tq.table` — check the view registry.
2635                let maybe_view = {
2636                    let views = self.inner.views.read();
2637                    views.get(&tq.table).cloned()
2638                };
2639                let Some(view) = maybe_view else {
2640                    return QueryExpr::Table(tq);
2641                };
2642
2643                // Issue #594 slice 9b — materialized views are read
2644                // from their backing collection, not by substituting
2645                // the body. Returning the TableQuery as-is lets the
2646                // normal table-read path resolve `SELECT FROM v`
2647                // against the collection provisioned at CREATE time.
2648                if view.materialized {
2649                    return QueryExpr::Table(tq);
2650                }
2651
2652                // Recurse into the view body — views may reference other
2653                // views. The recursion yields the final QueryExpr we need
2654                // to merge the outer's filter / limit / offset into.
2655                let inner_expr = self.rewrite_view_refs_inner((*view.query).clone());
2656
2657                // Phase 5: when the body is a Table we merge the outer
2658                // TableQuery's WHERE / LIMIT / OFFSET into it so stacked
2659                // views filter recursively. Non-table bodies (Search,
2660                // Ask, Vector, Graph, Hybrid) can't meaningfully combine
2661                // with an outer Table query today — return the body
2662                // verbatim; outer predicates are lost. Full projection
2663                // merge lands in Phase 5.2.
2664                match inner_expr {
2665                    QueryExpr::Table(mut inner_tq) => {
2666                        if let Some(outer_filter) = tq.filter.take() {
2667                            inner_tq.filter = Some(match inner_tq.filter.take() {
2668                                Some(existing) => {
2669                                    Filter::And(Box::new(existing), Box::new(outer_filter))
2670                                }
2671                                None => outer_filter,
2672                            });
2673                            // Keep the `Expr` form in lock-step with the
2674                            // merged `Filter`. The executor prefers
2675                            // `where_expr` and nulls `filter` when it is
2676                            // present (see `execute_query_inner`), so a
2677                            // stacked view whose outer predicate was only
2678                            // merged into `filter` would silently drop that
2679                            // predicate at eval time (#635).
2680                            inner_tq.where_expr = inner_tq
2681                                .filter
2682                                .as_ref()
2683                                .map(reddb_rql::sql_lowering::filter_to_expr);
2684                        }
2685                        if let Some(outer_limit) = tq.limit {
2686                            inner_tq.limit = Some(match inner_tq.limit {
2687                                Some(existing) => existing.min(outer_limit),
2688                                None => outer_limit,
2689                            });
2690                        }
2691                        if let Some(outer_offset) = tq.offset {
2692                            inner_tq.offset = Some(match inner_tq.offset {
2693                                Some(existing) => existing + outer_offset,
2694                                None => outer_offset,
2695                            });
2696                        }
2697                        QueryExpr::Table(inner_tq)
2698                    }
2699                    other => other,
2700                }
2701            }
2702            QueryExpr::Join(mut jq) => {
2703                jq.left = Box::new(self.rewrite_view_refs_inner(*jq.left));
2704                jq.right = Box::new(self.rewrite_view_refs_inner(*jq.right));
2705                QueryExpr::Join(jq)
2706            }
2707            // Other variants don't carry nested QueryExpr that can reference
2708            // a view by table name. Return as-is.
2709            other => other,
2710        }
2711    }
2712
2713    fn resolve_table_expr_subqueries(
2714        &self,
2715        mut table: TableQuery,
2716        frame: &dyn super::statement_frame::ReadFrame,
2717    ) -> RedDBResult<TableQuery> {
2718        // Only a `Subquery` source needs recursive resolution. `.take()`
2719        // would otherwise drop a `Name` / `Function` source on the floor
2720        // (the `if let` skips the body but the take already cleared it),
2721        // which silently broke `SELECT * FROM components(g)` — the TVF
2722        // dispatch downstream keys off `TableSource::Function` and never
2723        // fired. Restore any non-subquery source unchanged (issue #795).
2724        match table.source.take() {
2725            Some(TableSource::Subquery(inner)) => {
2726                let inner = self.resolve_select_expr_subqueries(*inner, frame)?;
2727                table.source = Some(TableSource::Subquery(Box::new(inner)));
2728            }
2729            other => table.source = other,
2730        }
2731
2732        let outer_scopes = relation_scopes_for_query(&QueryExpr::Table(table.clone()));
2733        for item in &mut table.select_items {
2734            if let reddb_rql::ast::SelectItem::Expr { expr, .. } = item {
2735                *expr = self.resolve_expr_subqueries(expr.clone(), &outer_scopes, frame)?;
2736            }
2737        }
2738        if let Some(where_expr) = table.where_expr.take() {
2739            table.where_expr =
2740                Some(self.resolve_expr_subqueries(where_expr, &outer_scopes, frame)?);
2741            table.filter = None;
2742        }
2743        if let Some(having_expr) = table.having_expr.take() {
2744            table.having_expr =
2745                Some(self.resolve_expr_subqueries(having_expr, &outer_scopes, frame)?);
2746            table.having = None;
2747        }
2748        for expr in &mut table.group_by_exprs {
2749            *expr = self.resolve_expr_subqueries(expr.clone(), &outer_scopes, frame)?;
2750        }
2751        for clause in &mut table.order_by {
2752            if let Some(expr) = clause.expr.take() {
2753                clause.expr = Some(self.resolve_expr_subqueries(expr, &outer_scopes, frame)?);
2754            }
2755        }
2756        Ok(table)
2757    }
2758
2759    fn resolve_select_expr_subqueries(
2760        &self,
2761        expr: QueryExpr,
2762        frame: &dyn super::statement_frame::ReadFrame,
2763    ) -> RedDBResult<QueryExpr> {
2764        match expr {
2765            QueryExpr::Table(table) => self
2766                .resolve_table_expr_subqueries(table, frame)
2767                .map(QueryExpr::Table),
2768            QueryExpr::Join(mut join) => {
2769                join.left = Box::new(self.resolve_select_expr_subqueries(*join.left, frame)?);
2770                join.right = Box::new(self.resolve_select_expr_subqueries(*join.right, frame)?);
2771                Ok(QueryExpr::Join(join))
2772            }
2773            other => Ok(other),
2774        }
2775    }
2776
2777    fn resolve_expr_subqueries(
2778        &self,
2779        expr: reddb_rql::ast::Expr,
2780        outer_scopes: &[String],
2781        frame: &dyn super::statement_frame::ReadFrame,
2782    ) -> RedDBResult<reddb_rql::ast::Expr> {
2783        use reddb_rql::ast::Expr;
2784
2785        match expr {
2786            Expr::Subquery { query, span } => {
2787                let values = self.execute_expr_subquery_values(query, outer_scopes, frame)?;
2788                if values.len() > 1 {
2789                    return Err(RedDBError::Query(
2790                        "scalar subquery returned more than one row".to_string(),
2791                    ));
2792                }
2793                Ok(Expr::Literal {
2794                    value: values.into_iter().next().unwrap_or(Value::Null),
2795                    span,
2796                })
2797            }
2798            Expr::BinaryOp { op, lhs, rhs, span } => Ok(Expr::BinaryOp {
2799                op,
2800                lhs: Box::new(self.resolve_expr_subqueries(*lhs, outer_scopes, frame)?),
2801                rhs: Box::new(self.resolve_expr_subqueries(*rhs, outer_scopes, frame)?),
2802                span,
2803            }),
2804            Expr::UnaryOp { op, operand, span } => Ok(Expr::UnaryOp {
2805                op,
2806                operand: Box::new(self.resolve_expr_subqueries(*operand, outer_scopes, frame)?),
2807                span,
2808            }),
2809            Expr::Cast {
2810                inner,
2811                target,
2812                span,
2813            } => Ok(Expr::Cast {
2814                inner: Box::new(self.resolve_expr_subqueries(*inner, outer_scopes, frame)?),
2815                target,
2816                span,
2817            }),
2818            Expr::FunctionCall { name, args, span } => {
2819                let args = args
2820                    .into_iter()
2821                    .map(|arg| self.resolve_expr_subqueries(arg, outer_scopes, frame))
2822                    .collect::<RedDBResult<Vec<_>>>()?;
2823                Ok(Expr::FunctionCall { name, args, span })
2824            }
2825            Expr::WindowFunctionCall {
2826                name,
2827                args,
2828                mut window,
2829                span,
2830            } => {
2831                let args = args
2832                    .into_iter()
2833                    .map(|arg| self.resolve_expr_subqueries(arg, outer_scopes, frame))
2834                    .collect::<RedDBResult<Vec<_>>>()?;
2835                for expr in &mut window.partition_by {
2836                    *expr = self.resolve_expr_subqueries(expr.clone(), outer_scopes, frame)?;
2837                }
2838                for clause in &mut window.order_by {
2839                    clause.expr =
2840                        self.resolve_expr_subqueries(clause.expr.clone(), outer_scopes, frame)?;
2841                }
2842                Ok(Expr::WindowFunctionCall {
2843                    name,
2844                    args,
2845                    window,
2846                    span,
2847                })
2848            }
2849            Expr::Case {
2850                branches,
2851                else_,
2852                span,
2853            } => {
2854                let branches = branches
2855                    .into_iter()
2856                    .map(|(cond, value)| {
2857                        Ok((
2858                            self.resolve_expr_subqueries(cond, outer_scopes, frame)?,
2859                            self.resolve_expr_subqueries(value, outer_scopes, frame)?,
2860                        ))
2861                    })
2862                    .collect::<RedDBResult<Vec<_>>>()?;
2863                let else_ = else_
2864                    .map(|expr| self.resolve_expr_subqueries(*expr, outer_scopes, frame))
2865                    .transpose()?
2866                    .map(Box::new);
2867                Ok(Expr::Case {
2868                    branches,
2869                    else_,
2870                    span,
2871                })
2872            }
2873            Expr::IsNull {
2874                operand,
2875                negated,
2876                span,
2877            } => Ok(Expr::IsNull {
2878                operand: Box::new(self.resolve_expr_subqueries(*operand, outer_scopes, frame)?),
2879                negated,
2880                span,
2881            }),
2882            Expr::InList {
2883                target,
2884                values,
2885                negated,
2886                span,
2887            } => {
2888                let target =
2889                    Box::new(self.resolve_expr_subqueries(*target, outer_scopes, frame)?);
2890                let mut resolved = Vec::new();
2891                for value in values {
2892                    if let Expr::Subquery { query, .. } = value {
2893                        resolved.extend(
2894                            self.execute_expr_subquery_values(query, outer_scopes, frame)?
2895                                .into_iter()
2896                                .map(Expr::lit),
2897                        );
2898                    } else {
2899                        resolved.push(self.resolve_expr_subqueries(value, outer_scopes, frame)?);
2900                    }
2901                }
2902                Ok(Expr::InList {
2903                    target,
2904                    values: resolved,
2905                    negated,
2906                    span,
2907                })
2908            }
2909            Expr::Between {
2910                target,
2911                low,
2912                high,
2913                negated,
2914                span,
2915            } => Ok(Expr::Between {
2916                target: Box::new(self.resolve_expr_subqueries(*target, outer_scopes, frame)?),
2917                low: Box::new(self.resolve_expr_subqueries(*low, outer_scopes, frame)?),
2918                high: Box::new(self.resolve_expr_subqueries(*high, outer_scopes, frame)?),
2919                negated,
2920                span,
2921            }),
2922            other => Ok(other),
2923        }
2924    }
2925
2926    fn execute_expr_subquery_values(
2927        &self,
2928        subquery: reddb_rql::ast::ExprSubquery,
2929        outer_scopes: &[String],
2930        frame: &dyn super::statement_frame::ReadFrame,
2931    ) -> RedDBResult<Vec<Value>> {
2932        let query = *subquery.query;
2933        if query_references_outer_scope(&query, outer_scopes) {
2934            return Err(RedDBError::Query(
2935                "NOT_YET_SUPPORTED: correlated subqueries are not supported yet; track follow-up issue #470-correlated-subqueries".to_string(),
2936            ));
2937        }
2938        let query = self.rewrite_view_refs(query);
2939        let query = self.resolve_select_expr_subqueries(query, frame)?;
2940        let query = self.authorize_relational_select_expr(query, frame)?;
2941        let result = match query {
2942            QueryExpr::Table(table) => {
2943                execute_runtime_table_query(&self.inner.db, &table, Some(&self.inner.index_store))?
2944            }
2945            QueryExpr::Join(join) => execute_runtime_join_query(self, &join)?,
2946            other => {
2947                return Err(RedDBError::Query(format!(
2948                    "expression subquery must be a SELECT query, got {}",
2949                    query_expr_name(&other)
2950                )))
2951            }
2952        };
2953        first_column_values(result)
2954    }
2955
2956    fn execute_materialized_graph(
2957        &self,
2958        query: &str,
2959        mode: QueryMode,
2960        statement: &'static str,
2961        expr: &QueryExpr,
2962    ) -> RedDBResult<RuntimeQueryResult> {
2963        // Apply MVCC visibility + RLS gate while materialising the
2964        // graph: every node entity is screened against the source
2965        // collection's policy chain (basic and `Nodes`-targeted)
2966        // and dropped when the caller's tenant / role doesn't
2967        // admit it. Edges are pruned automatically because the
2968        // graph builder skips edges whose endpoints aren't in
2969        // `allowed_nodes`.
2970        let (graph, node_properties, edge_properties) = self.materialize_graph_with_rls()?;
2971        let result =
2972            crate::storage::query::unified::UnifiedExecutor::execute_on_with_graph_properties_checked(
2973                &graph,
2974                expr,
2975                node_properties,
2976                edge_properties,
2977                super::function_budget::graph_work_check(),
2978            )
2979            .map_err(|err| RedDBError::Query(err.to_string()))?;
2980
2981        Ok(RuntimeQueryResult {
2982            query: query.to_string(),
2983            mode,
2984            statement,
2985            engine: "materialized-graph",
2986            result,
2987            affected_rows: 0,
2988            statement_type: "select",
2989            bookmark: None,
2990            notice: None,
2991        })
2992    }
2993
2994    fn dispatch_expr(
2995        &self,
2996        expr: QueryExpr,
2997        query_str: &str,
2998        mode: QueryMode,
2999    ) -> RedDBResult<RuntimeQueryResult> {
3000        // Typed read plans only: mutation/transaction cleanup must never fail
3001        // because a read result or cooperative deadline budget was exhausted.
3002        let is_read = super::query_budget::is_materialized_read(&expr);
3003        let _budget = if is_read {
3004            Some(super::query_budget::Scope::enter(
3005                self.resource_limits(),
3006                &self.inner.query_budget_slots,
3007            )?)
3008        } else {
3009            None
3010        };
3011        let result = self.dispatch_expr_inner(expr, query_str, mode);
3012        if is_read {
3013            super::query_budget::charge(0)?;
3014        }
3015        let result = result?;
3016        super::query_budget::check_result(self.resource_limits(), &result)?;
3017        if is_read {
3018            super::query_budget::charge(0)?;
3019        }
3020        Ok(result)
3021    }
3022
3023    fn dispatch_expr_inner(
3024        &self,
3025        expr: QueryExpr,
3026        query_str: &str,
3027        mode: QueryMode,
3028    ) -> RedDBResult<RuntimeQueryResult> {
3029        let statement = query_expr_name(&expr);
3030        match expr {
3031            QueryExpr::Function(command) => self.execute_function(query_str, &command),
3032            QueryExpr::Graph(_) | QueryExpr::Path(_) => {
3033                self.execute_materialized_graph(query_str, mode, statement, &expr)
3034            }
3035            QueryExpr::Table(table) => {
3036                let scope = self.ai_scope();
3037                let table = self.resolve_table_expr_subqueries(
3038                    table,
3039                    &scope as &dyn super::statement_frame::ReadFrame,
3040                )?;
3041                // Table-valued functions (e.g. components(g)) dispatch to a
3042                // read-only executor before any catalog/virtual-table routing
3043                // (issue #795).
3044                if let Some(TableSource::Function {
3045                    name,
3046                    args,
3047                    named_args,
3048                }) = table.source.clone()
3049                {
3050                    return Ok(RuntimeQueryResult {
3051                        query: query_str.to_string(),
3052                        mode,
3053                        statement,
3054                        engine: "runtime-graph-tvf",
3055                        result: self.execute_table_function(&name, &args, &named_args)?,
3056                        affected_rows: 0,
3057                        statement_type: "select",
3058                        bookmark: None,
3059                        notice: None,
3060                    });
3061                }
3062                // Inline-graph TVF (issue #799) on the prepared-statement /
3063                // direct-expr path. Result caching is wired on the
3064                // `execute_query_inner` path; here we just compute and return.
3065                if let Some(TableSource::InlineGraphFunction {
3066                    name,
3067                    nodes,
3068                    edges,
3069                    named_args,
3070                }) = table.source.clone()
3071                {
3072                    return Ok(RuntimeQueryResult {
3073                        query: query_str.to_string(),
3074                        mode,
3075                        statement,
3076                        engine: "runtime-graph-tvf-inline",
3077                        result: self.execute_inline_graph_function(
3078                            &name,
3079                            &nodes,
3080                            &edges,
3081                            &named_args,
3082                        )?,
3083                        affected_rows: 0,
3084                        statement_type: "select",
3085                        bookmark: None,
3086                        notice: None,
3087                    });
3088                }
3089                if super::red_schema::is_virtual_table(&table.table) {
3090                    return Ok(RuntimeQueryResult {
3091                        query: query_str.to_string(),
3092                        mode,
3093                        statement,
3094                        engine: "runtime-red-schema",
3095                        result: super::red_schema::red_query(
3096                            self,
3097                            &table.table,
3098                            &table,
3099                            &scope as &dyn super::statement_frame::ReadFrame,
3100                        )?,
3101                        affected_rows: 0,
3102                        statement_type: "select",
3103                        bookmark: None,
3104                        notice: None,
3105                    });
3106                }
3107                // `<graph>.<output>` analytics virtual view (issue #800).
3108                if let Some(view_result) = self.try_resolve_analytics_view(
3109                    &table,
3110                    &scope as &dyn super::statement_frame::ReadFrame,
3111                )? {
3112                    return Ok(RuntimeQueryResult {
3113                        query: query_str.to_string(),
3114                        mode,
3115                        statement,
3116                        engine: "runtime-graph-analytics-view",
3117                        result: view_result,
3118                        affected_rows: 0,
3119                        statement_type: "select",
3120                        bookmark: None,
3121                        notice: None,
3122                    });
3123                }
3124                let Some(table_with_rls) = self.authorize_relational_table_select(
3125                    table,
3126                    &scope as &dyn super::statement_frame::ReadFrame,
3127                )?
3128                else {
3129                    return Ok(RuntimeQueryResult {
3130                        query: query_str.to_string(),
3131                        mode,
3132                        statement,
3133                        engine: "runtime-table-rls",
3134                        result: crate::storage::query::unified::UnifiedResult::empty(),
3135                        affected_rows: 0,
3136                        statement_type: "select",
3137                        bookmark: None,
3138                        notice: None,
3139                    });
3140                };
3141                Ok(RuntimeQueryResult {
3142                    query: query_str.to_string(),
3143                    mode,
3144                    statement,
3145                    engine: "runtime-table",
3146                    result: execute_runtime_table_query(
3147                        &self.inner.db,
3148                        &table_with_rls,
3149                        Some(&self.inner.index_store),
3150                    )?,
3151                    affected_rows: 0,
3152                    statement_type: "select",
3153                    bookmark: None,
3154                    notice: None,
3155                })
3156            }
3157            QueryExpr::Join(join) => {
3158                let scope = self.ai_scope();
3159                let Some(join_with_rls) = self.authorize_relational_join_select(
3160                    join,
3161                    &scope as &dyn super::statement_frame::ReadFrame,
3162                )?
3163                else {
3164                    return Ok(RuntimeQueryResult {
3165                        query: query_str.to_string(),
3166                        mode,
3167                        statement,
3168                        engine: "runtime-join-rls",
3169                        result: crate::storage::query::unified::UnifiedResult::empty(),
3170                        affected_rows: 0,
3171                        statement_type: "select",
3172                        bookmark: None,
3173                        notice: None,
3174                    });
3175                };
3176                Ok(RuntimeQueryResult {
3177                    query: query_str.to_string(),
3178                    mode,
3179                    statement,
3180                    engine: "runtime-join",
3181                    result: execute_runtime_join_query(self, &join_with_rls)?,
3182                    affected_rows: 0,
3183                    statement_type: "select",
3184                    bookmark: None,
3185                    notice: None,
3186                })
3187            }
3188            QueryExpr::Vector(vector) => Ok(RuntimeQueryResult {
3189                query: query_str.to_string(),
3190                mode,
3191                statement,
3192                engine: "runtime-vector",
3193                result: execute_runtime_vector_query(self, &vector)?,
3194                affected_rows: 0,
3195                statement_type: "select",
3196                bookmark: None,
3197                notice: None,
3198            }),
3199            QueryExpr::Hybrid(hybrid) => Ok(RuntimeQueryResult {
3200                query: query_str.to_string(),
3201                mode,
3202                statement,
3203                engine: "runtime-hybrid",
3204                result: execute_runtime_hybrid_query(self, &hybrid)?,
3205                affected_rows: 0,
3206                statement_type: "select",
3207                bookmark: None,
3208                notice: None,
3209            }),
3210            QueryExpr::Insert(ref insert) if super::red_schema::is_virtual_table(&insert.table) => {
3211                Err(RedDBError::Query(
3212                    super::red_schema::READ_ONLY_ERROR.to_string(),
3213                ))
3214            }
3215            QueryExpr::Update(ref update) if super::red_schema::is_virtual_table(&update.table) => {
3216                Err(RedDBError::Query(
3217                    super::red_schema::READ_ONLY_ERROR.to_string(),
3218                ))
3219            }
3220            QueryExpr::Delete(ref delete) if super::red_schema::is_virtual_table(&delete.table) => {
3221                Err(RedDBError::Query(
3222                    super::red_schema::READ_ONLY_ERROR.to_string(),
3223                ))
3224            }
3225            QueryExpr::Insert(ref insert) => self.execute_insert(query_str, insert),
3226            QueryExpr::Update(ref update) => self
3227                .with_deferred_store_wal_for_dml(self.update_may_emit_events(update), || {
3228                    self.execute_update(query_str, update)
3229                }),
3230            QueryExpr::Delete(ref delete) => self
3231                .with_deferred_store_wal_for_dml(self.delete_may_emit_events(delete), || {
3232                    self.execute_delete(query_str, delete)
3233                }),
3234            QueryExpr::QueueCommand(ref cmd) => self.with_deferred_store_wal_if_transaction(|| {
3235                self.execute_queue_command(query_str, cmd)
3236            }),
3237            QueryExpr::KvCommand(ref cmd) => self.execute_kv_command(query_str, cmd),
3238            QueryExpr::SearchCommand(ref cmd) => self.execute_search_command(query_str, cmd),
3239            QueryExpr::Ask(ref ask) => self.execute_ask(query_str, ask),
3240            _ => Err(RedDBError::Query(format!(
3241                "prepared-statement execution does not support {statement} statements"
3242            ))),
3243        }
3244    }
3245
3246    /// Dispatch a graph-collection table-valued function call in FROM
3247    /// position (e.g. `SELECT * FROM components(g)`).
3248    ///
3249    /// Validates the function name and arity here, materializes the whole
3250    /// active graph read-only, then runs the algorithm via the shared
3251    /// `dispatch_graph_algorithm` path. Never mutates the catalog or store.
3252    fn execute_table_function(
3253        &self,
3254        name: &str,
3255        args: &[String],
3256        named_args: &[(String, f64)],
3257    ) -> RedDBResult<crate::storage::query::unified::UnifiedResult> {
3258        if name.eq_ignore_ascii_case("red.diff") {
3259            return self.execute_vcs_diff_tvf(args, named_args);
3260        }
3261        if !is_graph_tvf_name(name) {
3262            return Err(RedDBError::Query(format!("unknown table function: {name}")));
3263        }
3264        // Every graph-collection TVF takes exactly one graph argument.
3265        if args.len() != 1 {
3266            return Err(RedDBError::Query(format!(
3267                "table function '{name}' takes exactly 1 graph argument, got {}",
3268                args.len()
3269            )));
3270        }
3271
3272        // Read-only materialization of the full active graph. Passing `None`
3273        // for the projection uses the full graph store. Like #795/#796, the
3274        // v0 form runs over the whole graph store regardless of the collection
3275        // argument value. Materialization never mutates any store.
3276        let (nodes, edges) = self.materialize_whole_graph_abstract()?;
3277        self.dispatch_graph_algorithm(name, nodes, edges, named_args)
3278    }
3279
3280    pub(crate) fn revive_versioned_updates_since(
3281        &self,
3282        conn_id: u64,
3283        stamper_xid: u64,
3284    ) -> RedDBResult<usize> {
3285        // Snapshot undo identities without retaining the journal while waiting
3286        // for topology. Writers take topology before recording their undo entry.
3287        let candidates = {
3288            let guard = self.inner.pending_versioned_updates.read();
3289            let Some(pending) = guard.get(&conn_id) else {
3290                return Ok(0);
3291            };
3292            pending
3293                .iter()
3294                .rev()
3295                .filter(|entry| entry.3 >= stamper_xid)
3296                .cloned()
3297                .collect::<Vec<_>>()
3298        };
3299        let mut reverted = 0usize;
3300        for candidate in candidates {
3301            let (collection, old_id, new_id, xid, previous_xmax) = &candidate;
3302            let topology_lock = self.index_store_ref().collection_topology_lock(collection);
3303            #[cfg(test)]
3304            self.index_store_ref().mutation_test_hook(
3305                collection,
3306                super::index_store::MutationTestPhase::BeforeUndoTopology,
3307            );
3308            let topology_guard = topology_lock.read();
3309            let mut guard = self.inner.pending_versioned_updates.write();
3310            let Some(pending) = guard.get_mut(&conn_id) else {
3311                continue;
3312            };
3313            // Revalidate after waiting. Remove only successfully restored entries;
3314            // an error leaves this entry and the remaining undo journal intact.
3315            let Some(position) = pending.iter().rposition(|entry| entry == &candidate) else {
3316                continue;
3317            };
3318            self.revive_versioned_update(
3319                collection,
3320                *old_id,
3321                *new_id,
3322                *xid,
3323                *previous_xmax,
3324                topology_guard,
3325            )?;
3326            pending.remove(position);
3327            reverted += 1;
3328            if pending.is_empty() {
3329                guard.remove(&conn_id);
3330            }
3331        }
3332        Ok(reverted)
3333    }
3334
3335    /// `EXPLAIN` is peeled off the statement text before parsing, so the
3336    /// planner ran on the inner statement with no privilege check at all:
3337    /// a read-only principal could plan any write and read the table's
3338    /// column list and row estimate back out of the plan. The plan needs
3339    /// exactly the privilege the statement it describes needs.
3340    fn check_explain_target_privilege(&self, inner_sql: &str) -> RedDBResult<()> {
3341        let parsed =
3342            reddb_rql::parser::parse(inner_sql).map_err(|e| RedDBError::Query(e.to_string()))?;
3343        self.check_query_privilege(&parsed.query)
3344            .map_err(|err| RedDBError::Query(format!("permission denied: {err}")))
3345    }
3346
3347    /// Wrap the planner's `RuntimeQueryExplain` as rows on a
3348    /// `RuntimeQueryResult` so callers over the SQL interface see the
3349    /// plan tree in the same shape a SELECT produces.
3350    ///
3351    /// Columns: `op`, `source`, `estimated_rows`, `estimated_cost`, `depth`.
3352    /// Nodes are walked depth-first; `depth` counts from 0 at the
3353    /// root so a text renderer can indent without re-walking.
3354    fn explain_as_rows(&self, raw_query: &str, inner_sql: &str) -> RedDBResult<RuntimeQueryResult> {
3355        self.check_explain_target_privilege(inner_sql)?;
3356        let explain = self.explain_query(inner_sql)?;
3357
3358        let mut columns = vec![
3359            "op".to_string(),
3360            "source".to_string(),
3361            "estimated_rows".to_string(),
3362            "estimated_cost".to_string(),
3363            "depth".to_string(),
3364        ];
3365
3366        let mut records: Vec<crate::storage::query::unified::UnifiedRecord> = Vec::new();
3367
3368        // Prepend `CteScan` markers when the query carried a leading
3369        // WITH clause. The CTE bodies are already inlined into the
3370        // main plan tree, but operators reading EXPLAIN need to see
3371        // which named CTEs were resolved — without this row the plan
3372        // would look indistinguishable from a hand-inlined query.
3373        for name in &explain.cte_materializations {
3374            use std::sync::Arc;
3375            let mut rec = crate::storage::query::unified::UnifiedRecord::default();
3376            rec.set_arc(Arc::from("op"), Value::text("CteScan".to_string()));
3377            rec.set_arc(Arc::from("source"), Value::text(name.clone()));
3378            rec.set_arc(Arc::from("estimated_rows"), Value::Float(0.0));
3379            rec.set_arc(Arc::from("estimated_cost"), Value::Float(0.0));
3380            rec.set_arc(Arc::from("depth"), Value::Integer(0));
3381            records.push(rec);
3382        }
3383
3384        let vector_details = explain.statement == "vector";
3385        if vector_details {
3386            columns.extend(["access_path_reason".to_string(), "index_hint".to_string()]);
3387        }
3388        walk_plan_node(&explain.logical_plan.root, 0, &mut records, vector_details);
3389
3390        let result = crate::storage::query::unified::UnifiedResult {
3391            columns,
3392            records,
3393            stats: Default::default(),
3394            pre_serialized_json: None,
3395        };
3396
3397        Ok(RuntimeQueryResult {
3398            query: raw_query.to_string(),
3399            mode: explain.mode,
3400            statement: "explain",
3401            engine: "runtime-explain",
3402            result,
3403            affected_rows: 0,
3404            statement_type: "select",
3405            bookmark: None,
3406            notice: None,
3407        })
3408    }
3409
3410    fn explain_analyze_vector_as_rows(
3411        &self,
3412        raw_query: &str,
3413        expr: QueryExpr,
3414    ) -> RedDBResult<RuntimeQueryResult> {
3415        let QueryExpr::Vector(query) = &expr else {
3416            return Err(RedDBError::Internal(
3417                "expected vector analysis target".to_string(),
3418            ));
3419        };
3420        let source = query.collection.clone();
3421        // The typed entry installs the full statement frame and privilege gate,
3422        // and cannot serve an earlier result-cache measurement as a fresh run.
3423        let execution = self.execute_query_expr(expr)?;
3424        let stats = execution.result.stats;
3425        let vector = stats.vector.as_ref().ok_or_else(|| {
3426            RedDBError::Internal("vector executor did not provide measurements".to_string())
3427        })?;
3428        let fields = vec![
3429            ("op", Value::text(vector.access_path.clone())),
3430            ("source", Value::text(source)),
3431            ("index_used", Value::Boolean(vector.index_used)),
3432            ("mode_requested", Value::text(vector.mode_requested.clone())),
3433            ("mode_executed", Value::text(vector.mode_executed.clone())),
3434            (
3435                "fallback_reason",
3436                vector
3437                    .fallback_reason
3438                    .as_ref()
3439                    .map_or(Value::Null, |reason| Value::text(reason.clone())),
3440            ),
3441            (
3442                "approximate_distance_evaluations",
3443                Value::UnsignedInteger(vector.approximate_distance_evaluations),
3444            ),
3445            (
3446                "peak_topk_entries",
3447                Value::UnsignedInteger(vector.peak_topk_entries),
3448            ),
3449            (
3450                "candidates_examined",
3451                Value::UnsignedInteger(vector.candidates_examined),
3452            ),
3453            (
3454                "metadata_rejected",
3455                Value::UnsignedInteger(vector.metadata_rejected),
3456            ),
3457            ("rls_rejected", Value::UnsignedInteger(vector.rls_rejected)),
3458            (
3459                "visibility_rejected",
3460                Value::UnsignedInteger(vector.visibility_rejected),
3461            ),
3462            (
3463                "exact_distance_evaluations",
3464                Value::UnsignedInteger(vector.exact_distance_evaluations),
3465            ),
3466            ("actual_rows", Value::UnsignedInteger(vector.rows_returned)),
3467            (
3468                "actual_ms",
3469                Value::Float(stats.exec_time_us as f64 / 1000.0),
3470            ),
3471            ("metrics_scope", Value::text("vector_pipeline")),
3472            (
3473                "operators",
3474                Value::Json(vector.operators_json().to_string().into_bytes()),
3475            ),
3476        ];
3477        let columns = fields.iter().map(|(name, _)| name.to_string()).collect();
3478        let mut record = crate::storage::query::unified::UnifiedRecord::default();
3479        for (name, value) in fields {
3480            record.set_owned(name.to_string(), value);
3481        }
3482        Ok(RuntimeQueryResult {
3483            query: raw_query.to_string(),
3484            mode: execution.mode,
3485            statement: "explain_analyze",
3486            engine: "runtime-explain-analyze",
3487            result: crate::storage::query::unified::UnifiedResult {
3488                columns,
3489                records: vec![record],
3490                stats,
3491                pre_serialized_json: None,
3492            },
3493            affected_rows: 0,
3494            statement_type: "select",
3495            bookmark: None,
3496            notice: None,
3497        })
3498    }
3499
3500    fn explain_analyze_as_rows(
3501        &self,
3502        raw_query: &str,
3503        inner_sql: &str,
3504    ) -> RedDBResult<RuntimeQueryResult> {
3505        if strip_keyword_ci(inner_sql.trim_start(), "VECTOR").is_some() {
3506            let expr = parse_multi(inner_sql).map_err(|err| RedDBError::Query(err.to_string()))?;
3507            if matches!(&expr, QueryExpr::Vector(_)) {
3508                return self.explain_analyze_vector_as_rows(raw_query, expr);
3509            }
3510        }
3511        if !starts_with_dml_keyword(inner_sql) {
3512            return Err(RedDBError::Query(
3513                "EXPLAIN ANALYZE currently supports INSERT, UPDATE, DELETE, and VECTOR SEARCH"
3514                    .to_string(),
3515            ));
3516        }
3517
3518        self.check_explain_target_privilege(inner_sql)?;
3519        let explain = self.explain_query(inner_sql)?;
3520        let conn_id = current_connection_id();
3521        if self.inner.transaction_state.in_transaction(conn_id) {
3522            return Err(RedDBError::Query(
3523                "EXPLAIN ANALYZE requires no active transaction".to_string(),
3524            ));
3525        }
3526
3527        self.execute_query_inner("BEGIN")?;
3528        let started = std::time::Instant::now();
3529        let execution = self.execute_query_inner(inner_sql);
3530        let elapsed_ms = started.elapsed().as_secs_f64() * 1000.0;
3531        let rollback = self.execute_query_inner("ROLLBACK");
3532
3533        let affected_rows = match execution {
3534            Ok(result) => result.affected_rows,
3535            Err(err) => {
3536                rollback?;
3537                return Err(err);
3538            }
3539        };
3540        rollback?;
3541
3542        let columns = vec![
3543            "op".to_string(),
3544            "source".to_string(),
3545            "estimated_rows".to_string(),
3546            "estimated_cost".to_string(),
3547            "actual_rows".to_string(),
3548            "actual_ms".to_string(),
3549            "depth".to_string(),
3550        ];
3551        let mut records = Vec::new();
3552        walk_analyze_plan_node(
3553            &explain.logical_plan.root,
3554            0,
3555            affected_rows,
3556            elapsed_ms,
3557            &mut records,
3558        );
3559
3560        let result = crate::storage::query::unified::UnifiedResult {
3561            columns,
3562            records,
3563            stats: Default::default(),
3564            pre_serialized_json: None,
3565        };
3566
3567        Ok(RuntimeQueryResult {
3568            query: raw_query.to_string(),
3569            mode: explain.mode,
3570            statement: "explain_analyze",
3571            engine: "runtime-explain-analyze",
3572            result,
3573            affected_rows: 0,
3574            statement_type: "select",
3575            bookmark: None,
3576            notice: None,
3577        })
3578    }
3579}
3580
3581fn redact_sql_for_slow_log(query: &str) -> String {
3582    let lowered = query.to_ascii_lowercase();
3583    if lowered.contains("secret") || lowered.contains("vault") {
3584        return "secret statement (SQL redacted)".to_string();
3585    }
3586    let credential_candidate = lowered.contains("password")
3587        && ((lowered.contains("create") && lowered.contains("user"))
3588            || (lowered.contains("alter") && lowered.contains("user")));
3589    if !credential_candidate {
3590        return query.to_string();
3591    }
3592    match parse_multi(query) {
3593        Ok(QueryExpr::CreateUser(stmt)) => {
3594            let target = UserId::from_parts(stmt.tenant.as_deref(), &stmt.username);
3595            format!("CREATE USER {target} PASSWORD '***' ROLE {}", stmt.role)
3596        }
3597        Ok(QueryExpr::AlterUser(stmt))
3598            if stmt
3599                .attributes
3600                .iter()
3601                .any(|attr| matches!(attr, reddb_rql::ast::AlterUserAttribute::Password(_))) =>
3602        {
3603            let target = UserId::from_parts(stmt.tenant.as_deref(), &stmt.username);
3604            format!("ALTER USER {target} PASSWORD '***'")
3605        }
3606        Ok(_) => "credential statement (SQL redacted)".to_string(),
3607        Err(_) => "credential statement (SQL redacted)".to_string(),
3608    }
3609}
3610
3611fn strip_explain_analyze_prefix(sql: &str) -> Option<&str> {
3612    let rest = strip_keyword_ci(sql.trim_start(), "EXPLAIN")?.trim_start();
3613    Some(strip_keyword_ci(rest, "ANALYZE")?.trim_start()).filter(|inner| !inner.is_empty())
3614}
3615
3616fn strip_keyword_ci<'a>(sql: &'a str, keyword: &str) -> Option<&'a str> {
3617    // `get` is None past the end or inside a multi-byte character, where
3618    // `split_at` would panic (and a release build aborts on panic).
3619    let head = sql.get(..keyword.len())?;
3620    let rest = &sql[keyword.len()..];
3621    if !head.eq_ignore_ascii_case(keyword) {
3622        return None;
3623    }
3624    if rest
3625        .chars()
3626        .next()
3627        .is_some_and(|ch| !ch.is_ascii_whitespace())
3628    {
3629        return None;
3630    }
3631    Some(rest)
3632}
3633
3634fn starts_with_dml_keyword(sql: &str) -> bool {
3635    let trimmed = sql.trim_start();
3636    let head_end = trimmed
3637        .find(|ch: char| ch.is_ascii_whitespace())
3638        .unwrap_or(trimmed.len());
3639    let head = &trimmed[..head_end];
3640    head.eq_ignore_ascii_case("INSERT")
3641        || head.eq_ignore_ascii_case("UPDATE")
3642        || head.eq_ignore_ascii_case("DELETE")
3643}
3644
3645fn walk_plan_node(
3646    node: &crate::storage::query::planner::CanonicalLogicalNode,
3647    depth: usize,
3648    out: &mut Vec<crate::storage::query::unified::UnifiedRecord>,
3649    vector_details: bool,
3650) {
3651    use std::sync::Arc;
3652    let mut rec = crate::storage::query::unified::UnifiedRecord::default();
3653    rec.set_arc(Arc::from("op"), Value::text(node.operator.clone()));
3654    rec.set_arc(
3655        Arc::from("source"),
3656        node.source.clone().map(Value::text).unwrap_or(Value::Null),
3657    );
3658    rec.set_arc(
3659        Arc::from("estimated_rows"),
3660        Value::Float(node.estimated_rows),
3661    );
3662    rec.set_arc(
3663        Arc::from("estimated_cost"),
3664        Value::Float(node.operator_cost),
3665    );
3666    rec.set_arc(Arc::from("depth"), Value::Integer(depth as i64));
3667    if vector_details {
3668        for name in ["access_path_reason", "index_hint"] {
3669            rec.set_owned(
3670                name.to_string(),
3671                node.details
3672                    .get(name)
3673                    .cloned()
3674                    .map(Value::text)
3675                    .unwrap_or(Value::Null),
3676            );
3677        }
3678    }
3679    out.push(rec);
3680    for child in &node.children {
3681        walk_plan_node(child, depth + 1, out, vector_details);
3682    }
3683}
3684
3685fn walk_analyze_plan_node(
3686    node: &crate::storage::query::planner::CanonicalLogicalNode,
3687    depth: usize,
3688    affected_rows: u64,
3689    elapsed_ms: f64,
3690    out: &mut Vec<crate::storage::query::unified::UnifiedRecord>,
3691) {
3692    use std::sync::Arc;
3693    let mut rec = crate::storage::query::unified::UnifiedRecord::default();
3694    rec.set_arc(Arc::from("op"), Value::text(node.operator.clone()));
3695    rec.set_arc(
3696        Arc::from("source"),
3697        node.source.clone().map(Value::text).unwrap_or(Value::Null),
3698    );
3699    rec.set_arc(
3700        Arc::from("estimated_rows"),
3701        Value::Float(node.estimated_rows),
3702    );
3703    rec.set_arc(
3704        Arc::from("estimated_cost"),
3705        Value::Float(node.operator_cost),
3706    );
3707    rec.set_arc(
3708        Arc::from("actual_rows"),
3709        Value::UnsignedInteger(if depth == 0 { affected_rows } else { 0 }),
3710    );
3711    rec.set_arc(
3712        Arc::from("actual_ms"),
3713        Value::Float(if depth == 0 { elapsed_ms } else { 0.0 }),
3714    );
3715    rec.set_arc(Arc::from("depth"), Value::Integer(depth as i64));
3716    out.push(rec);
3717
3718    for child in &node.children {
3719        walk_analyze_plan_node(child, depth + 1, 0, 0.0, out);
3720    }
3721}
3722
3723#[cfg(test)]
3724mod declared_model_validation_tests {
3725    use super::*;
3726    use crate::catalog::CollectionModel;
3727    use crate::storage::no_malloc_hot_paths::measure_allocations;
3728    use reddb_rql::ast::KvCommand;
3729
3730    fn get(collection: &str) -> QueryExpr {
3731        QueryExpr::KvCommand(KvCommand::Get {
3732            model: CollectionModel::Kv,
3733            collection: collection.into(),
3734            key: "item".into(),
3735        })
3736    }
3737
3738    #[test]
3739    fn declared_model_validation_keeps_mismatches_and_observes_contract_changes() {
3740        let runtime = RedDBRuntime::in_memory().expect("runtime");
3741        runtime
3742            .execute_query("KV PUT declared.item = 1")
3743            .expect("seed");
3744        runtime
3745            .validate_model_operations_before_auth(&get("declared"))
3746            .expect("KV contract");
3747        let db = runtime.db();
3748        for (model, name) in [
3749            (CollectionModel::Table, "table"),
3750            (CollectionModel::Mixed, "mixed"),
3751        ] {
3752            let mut contract = db.collection_contract("declared").expect("contract");
3753            contract.declared_model = model;
3754            db.save_collection_contract(contract)
3755                .expect("replace contract");
3756            let error = runtime
3757                .validate_model_operations_before_auth(&get("declared"))
3758                .expect_err("mismatch");
3759            assert_eq!(
3760                error.to_string(),
3761                format!("INVALID_OPERATION: model mismatch: expected kv, got {name}")
3762            );
3763        }
3764    }
3765
3766    #[test]
3767    fn declared_model_validation_preserves_legacy_inference_and_missing_collections() {
3768        let runtime = RedDBRuntime::in_memory().expect("runtime");
3769        let db = runtime.db();
3770        let store = db.store();
3771        store
3772            .create_collection("legacy")
3773            .expect("legacy collection");
3774        store
3775            .get_collection("legacy")
3776            .expect("manager")
3777            .insert(crate::storage::UnifiedEntity::table_row(
3778                crate::storage::EntityId::new(1),
3779                "legacy",
3780                1,
3781                Vec::new(),
3782            ))
3783            .expect("uncontracted table row");
3784        assert!(db.collection_contract("legacy").is_none());
3785        let error = runtime
3786            .validate_model_operations_before_auth(&get("legacy"))
3787            .expect_err("inferred table");
3788        assert!(error
3789            .to_string()
3790            .contains("model mismatch: expected kv, got table"));
3791        runtime
3792            .validate_model_operations_before_auth(&get("missing"))
3793            .expect("missing stays deferred to execution");
3794    }
3795
3796    #[test]
3797    fn declared_model_validation_does_not_treat_orphan_contracts_as_collections() {
3798        let runtime = RedDBRuntime::in_memory().expect("runtime");
3799        runtime
3800            .execute_query("KV PUT declared.item = 1")
3801            .expect("seed");
3802        let db = runtime.db();
3803        let mut contract = db.collection_contract("declared").expect("contract");
3804        contract.name = "orphan".into();
3805        contract.declared_model = CollectionModel::Table;
3806        db.save_collection_contract(contract)
3807            .expect("orphan contract");
3808        assert!(db.store().get_collection("orphan").is_none());
3809        runtime
3810            .validate_model_operations_before_auth(&get("orphan"))
3811            .expect("no physical collection to validate");
3812    }
3813
3814    #[test]
3815    fn declared_model_validation_allocates_nothing_with_unrelated_collections() {
3816        let runtime = RedDBRuntime::in_memory().expect("runtime");
3817        runtime
3818            .execute_query("KV PUT declared.item = 1")
3819            .expect("seed");
3820        let expr = get("declared");
3821        for unrelated in [0, 64] {
3822            let db = runtime.db();
3823            let store = db.store();
3824            for index in 0..unrelated {
3825                let name = format!("unrelated_{index}");
3826                store
3827                    .create_collection(&name)
3828                    .expect("unrelated collection");
3829            }
3830            runtime
3831                .validate_model_operations_before_auth(&expr)
3832                .expect("warm contract cache");
3833            let (result, count) =
3834                measure_allocations(|| runtime.validate_model_operations_before_auth(&expr));
3835            result.expect("declared KV");
3836            assert_eq!(
3837                count.allocs, 0,
3838                "validation must only borrow the target contract"
3839            );
3840        }
3841    }
3842}
3843
3844#[cfg(test)]
3845mod multibyte_sql_tests {
3846    use super::*;
3847
3848    #[test]
3849    fn explain_analyze_prefix_ignores_multibyte_text_at_the_keyword_boundary() {
3850        // `strip_keyword_ci` used `split_at(keyword.len())`, which panics when
3851        // that byte offset is inside a multi-byte character.
3852        for pad in 0..16 {
3853            for wide in ["é", "日", "😀"] {
3854                let sql = format!("{}{wide}llo", "a".repeat(pad));
3855                assert_eq!(strip_explain_analyze_prefix(&sql), None, "{sql:?}");
3856            }
3857        }
3858        assert_eq!(
3859            strip_explain_analyze_prefix("explain  analyze DELETE FROM t"),
3860            Some("DELETE FROM t")
3861        );
3862    }
3863
3864    /// One malformed or unusual statement must never take the process down:
3865    /// release builds abort on panic, so any panic reachable from
3866    /// `execute_query` is a remote denial of service. Put a multi-byte
3867    /// character at every byte offset a keyword matcher might slice at.
3868    #[test]
3869    fn multibyte_sql_never_panics_execute_query() {
3870        let rt = RedDBRuntime::in_memory().expect("in-memory runtime");
3871        let seeds = [
3872            "",
3873            "SELECT ",
3874            "SELECT '",
3875            "EXPLAIN ",
3876            "EXPLAIN ANALYZE ",
3877            "CHECKPOINT ",
3878            "CHECKOUT ",
3879            "RESET ",
3880            "MERGE ",
3881            "CHERRY PICK ",
3882            "REVERT ",
3883            "RESOLVE CONFLICT ",
3884            "INSERT INTO t VALUES ('",
3885        ];
3886        let mut panicked = Vec::new();
3887        for seed in seeds {
3888            for pad in 0..16 {
3889                for wide in ["é", "日", "😀"] {
3890                    let sql = format!("{seed}{}{wide}llo", "a".repeat(pad));
3891                    let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
3892                        let _ = rt.execute_query(&sql);
3893                    }));
3894                    if outcome.is_err() {
3895                        panicked.push(sql);
3896                    }
3897                }
3898            }
3899        }
3900        assert!(
3901            panicked.is_empty(),
3902            "execute_query panicked on {} statement(s), first: {:?}",
3903            panicked.len(),
3904            panicked.first()
3905        );
3906    }
3907}
3908
3909#[cfg(test)]
3910mod inline_graph_tvf_tests {
3911    use super::*;
3912
3913    fn scopes_for(sql: &str) -> HashSet<String> {
3914        let expr = reddb_rql::parser::parse(sql).expect("parse").query;
3915        query_expr_result_cache_scopes(&expr)
3916    }
3917
3918    #[test]
3919    fn inline_tvf_cache_scopes_include_source_collections() {
3920        // The result-cache key for the inline form must derive from the
3921        // `nodes`/`edges` source collections so a write to either invalidates
3922        // the cached result (issue #799).
3923        let scopes = scopes_for(
3924            "SELECT * FROM components(nodes => (SELECT id FROM hosts), edges => (SELECT src, dst FROM links))",
3925        );
3926        assert!(scopes.contains("hosts"), "nodes source scoped: {scopes:?}");
3927        assert!(scopes.contains("links"), "edges source scoped: {scopes:?}");
3928    }
3929
3930    #[test]
3931    fn graph_collection_tvf_cache_scope_is_graph_argument() {
3932        // The graph-collection form still materializes the active graph, but
3933        // result-cache invalidation is scoped to the named graph argument so
3934        // INSERT INTO g NODE/EDGE invalidates cached TVF rows.
3935        let scopes = scopes_for("SELECT * FROM components(g)");
3936        assert!(scopes.contains("g"), "collection form scoped: {scopes:?}");
3937    }
3938
3939    #[test]
3940    fn abstract_degree_centrality_counts_undirected_endpoints() {
3941        let nodes = vec!["a".to_string(), "b".to_string(), "c".to_string()];
3942        let edges = vec![
3943            ("a".to_string(), "b".to_string(), 1.0_f32),
3944            ("b".to_string(), "c".to_string(), 1.0_f32),
3945        ];
3946        let degrees = abstract_degree_centrality(&nodes, &edges);
3947        assert_eq!(
3948            degrees,
3949            vec![
3950                ("a".to_string(), 1),
3951                ("b".to_string(), 2),
3952                ("c".to_string(), 1),
3953            ]
3954        );
3955    }
3956}
3957
3958/// Vault keys the engine itself owns. `SET SECRET` / `DELETE SECRET` share
3959/// the vault KV space with the IAM policy blobs, the attachment tables and
3960/// the `SECRET()` column master key; letting a principal with `secret:write`
3961/// overwrite them is an escalation (allow-all policy after restart) or a
3962/// permanent data loss (rotated AES key). Provider credentials under
3963/// `red.secret.ai.*` stay writable — that is the documented use.
3964fn reject_engine_internal_vault_key(key: &str) -> RedDBResult<()> {
3965    let lowered = key.to_ascii_lowercase();
3966    let internal = lowered == "red.secret.aes_key"
3967        || lowered.starts_with("red.iam.")
3968        || lowered.starts_with("red.acl.")
3969        || lowered.starts_with("red.vault.");
3970    if internal {
3971        return Err(RedDBError::Query(format!(
3972            "`{key}` is an engine-internal vault key and cannot be set or deleted through SET/DELETE SECRET"
3973        )));
3974    }
3975    Ok(())
3976}
3977
3978/// Optional confinement for `COPY … FROM '<path>'`. When
3979/// `REDDB_COPY_IMPORT_ROOT` is set, the (canonicalised) source path must live
3980/// under that directory; otherwise the statement is refused. Unset keeps the
3981/// historical behaviour (any readable file, admin-only via the privilege
3982/// gate).
3983fn confine_copy_from_path(path: &str) -> RedDBResult<()> {
3984    let Ok(root) = std::env::var("REDDB_COPY_IMPORT_ROOT") else {
3985        return Ok(());
3986    };
3987    let root = std::fs::canonicalize(&root).map_err(|e| {
3988        RedDBError::Query(format!(
3989            "REDDB_COPY_IMPORT_ROOT `{root}` is not usable: {e}"
3990        ))
3991    })?;
3992    let resolved = std::fs::canonicalize(path)
3993        .map_err(|e| RedDBError::Query(format!("COPY FROM `{path}`: {e}")))?;
3994    if !resolved.starts_with(&root) || !resolved.is_file() {
3995        return Err(RedDBError::Query(format!(
3996            "COPY FROM `{path}` is outside the configured import root"
3997        )));
3998    }
3999    Ok(())
4000}
4001
4002#[cfg(test)]
4003mod result_cache_execution_tests {
4004    use super::*;
4005
4006    const QUERY: &str = "SELECT SUM(n) AS n FROM result_cache_probe";
4007
4008    fn runtime_with_rows(cache_enabled: bool) -> RedDBRuntime {
4009        let runtime = RedDBRuntime::in_memory().expect("runtime");
4010        runtime
4011            .execute_query("CREATE TABLE result_cache_probe (id INT, n INT)")
4012            .expect("create table");
4013        runtime
4014            .execute_query(
4015                "INSERT INTO result_cache_probe (id, n) VALUES (1, 10), (2, 20), (3, 30)",
4016            )
4017            .expect("seed rows");
4018        runtime
4019            .execute_query(&format!(
4020                "SET CONFIG runtime.result_cache.enabled = {cache_enabled}"
4021            ))
4022            .expect("configure result cache");
4023        runtime
4024    }
4025
4026    fn aggregate_value(runtime: &RedDBRuntime) -> i64 {
4027        let result = runtime.execute_query(QUERY).expect("aggregate query");
4028        match result.result.records[0].get("n") {
4029            Some(reddb_types::Value::Integer(value)) => *value,
4030            other => panic!("expected integer aggregate, got {other:?}"),
4031        }
4032    }
4033
4034    #[test]
4035    fn hot_hit_bypasses_query_execution_pipeline() {
4036        let runtime = runtime_with_rows(true);
4037        reset_result_cache_miss_executions();
4038        let before = runtime.result_cache_metrics();
4039
4040        assert_eq!(aggregate_value(&runtime), 60);
4041        assert_eq!(result_cache_miss_executions(), 1);
4042        assert_eq!(aggregate_value(&runtime), 60);
4043
4044        let after = runtime.result_cache_metrics();
4045        assert_eq!(
4046            result_cache_miss_executions(),
4047            1,
4048            "hit re-entered the executor"
4049        );
4050        assert_eq!(after.0 - before.0, 1, "second read must be a cache hit");
4051        assert_eq!(after.1 - before.1, 1, "first read must populate the cache");
4052    }
4053
4054    #[test]
4055    fn disabled_cache_executes_every_read() {
4056        let runtime = runtime_with_rows(false);
4057        reset_result_cache_miss_executions();
4058        let before = runtime.result_cache_metrics();
4059
4060        assert_eq!(aggregate_value(&runtime), 60);
4061        assert_eq!(aggregate_value(&runtime), 60);
4062
4063        assert_eq!(result_cache_miss_executions(), 2);
4064        assert_eq!(
4065            runtime.result_cache_metrics(),
4066            before,
4067            "disabled cache must not report lookups"
4068        );
4069    }
4070
4071    #[test]
4072    fn table_write_forces_one_reexecution_then_hits_again() {
4073        let runtime = runtime_with_rows(true);
4074        assert_eq!(aggregate_value(&runtime), 60);
4075        assert_eq!(aggregate_value(&runtime), 60);
4076        runtime
4077            .execute_query("INSERT INTO result_cache_probe (id, n) VALUES (4, 40)")
4078            .expect("mutate cached table");
4079        reset_result_cache_miss_executions();
4080        let before = runtime.result_cache_metrics();
4081
4082        assert_eq!(aggregate_value(&runtime), 100);
4083        assert_eq!(result_cache_miss_executions(), 1);
4084        assert_eq!(aggregate_value(&runtime), 100);
4085
4086        let after = runtime.result_cache_metrics();
4087        assert_eq!(result_cache_miss_executions(), 1);
4088        assert_eq!(after.0 - before.0, 1, "post-write result must become hot");
4089        assert_eq!(
4090            after.1 - before.1,
4091            1,
4092            "write must invalidate the old result"
4093        );
4094    }
4095}
4096
4097#[cfg(test)]
4098mod security_gate_tests {
4099    use super::*;
4100    use crate::auth::store::AuthStore;
4101    use crate::auth::{AuthConfig, Role};
4102
4103    /// Clears the thread-local identity on drop so one test's principal
4104    /// never leaks into the next on a shared thread.
4105    struct IdentityGuard;
4106
4107    impl IdentityGuard {
4108        fn install(username: &str, role: Role) -> Self {
4109            set_current_auth_identity(username.to_string(), role);
4110            Self
4111        }
4112    }
4113
4114    impl Drop for IdentityGuard {
4115        fn drop(&mut self) {
4116            clear_current_auth_identity();
4117        }
4118    }
4119
4120    struct ScopedIdentityGuard;
4121
4122    impl ScopedIdentityGuard {
4123        fn install(tenant: &str, username: &str, role: Role) -> Self {
4124            crate::runtime::execution_context::set_current_auth_principal(
4125                crate::auth::UserId::scoped(tenant, username),
4126                role,
4127            );
4128            set_current_tenant(tenant.to_string());
4129            Self
4130        }
4131    }
4132
4133    impl Drop for ScopedIdentityGuard {
4134        fn drop(&mut self) {
4135            clear_current_auth_identity();
4136            clear_current_tenant();
4137        }
4138    }
4139
4140    fn runtime_with_user(username: &str, role: Role) -> RedDBRuntime {
4141        let store = std::sync::Arc::new(AuthStore::new(AuthConfig {
4142            enabled: true,
4143            session_ttl_secs: 60,
4144            require_auth: true,
4145            auto_encrypt_storage: false,
4146            vault_enabled: false,
4147            cert: Default::default(),
4148            oauth: Default::default(),
4149        }));
4150        store.create_user(username, "pw", role).expect("user");
4151        let runtime =
4152            RedDBRuntime::with_options(crate::api::RedDBOptions::in_memory()).expect("runtime");
4153        runtime.set_auth_store(store);
4154        runtime
4155    }
4156
4157    fn runtime_with_store(username: &str, role: Role) -> (RedDBRuntime, std::sync::Arc<AuthStore>) {
4158        let store = std::sync::Arc::new(AuthStore::new(AuthConfig {
4159            enabled: true,
4160            session_ttl_secs: 60,
4161            require_auth: true,
4162            auto_encrypt_storage: false,
4163            vault_enabled: false,
4164            cert: Default::default(),
4165            oauth: Default::default(),
4166        }));
4167        store.create_user(username, "pw", role).expect("user");
4168        let runtime =
4169            RedDBRuntime::with_options(crate::api::RedDBOptions::in_memory()).expect("runtime");
4170        runtime.set_auth_store(std::sync::Arc::clone(&store));
4171        (runtime, store)
4172    }
4173
4174    #[test]
4175    fn tenant_credential_cannot_override_credential_scope() {
4176        use crate::runtime::within_clause::{FieldOverride, ScopeOverride};
4177
4178        let store = std::sync::Arc::new(AuthStore::new(AuthConfig {
4179            enabled: true,
4180            require_auth: true,
4181            ..AuthConfig::default()
4182        }));
4183        store
4184            .create_user_in_tenant(Some("acme"), "admin", "pw", Role::Admin)
4185            .expect("tenant admin");
4186        let runtime =
4187            RedDBRuntime::with_options(crate::api::RedDBOptions::in_memory()).expect("runtime");
4188        runtime.set_auth_store(store);
4189        let _identity = ScopedIdentityGuard::install("acme", "admin", Role::Admin);
4190
4191        runtime
4192            .execute_query("WITHIN TENANT 'acme' SELECT 1")
4193            .expect("own tenant remains valid");
4194        for sql in [
4195            "WITHIN TENANT 'globex' SELECT 1",
4196            "WITHIN TENANT NULL SELECT 1",
4197            "WITHIN TENANT 'acme' USER 'other' SELECT 1",
4198            "WITHIN TENANT 'acme' AS ROLE 'read' SELECT 1",
4199        ] {
4200            assert!(runtime.execute_query(sql).is_err(), "must reject {sql}");
4201        }
4202        assert!(runtime
4203            .execute_query_with_scope(
4204                "SELECT 1",
4205                ScopeOverride {
4206                    tenant: FieldOverride::Set("globex".to_string()),
4207                    ..ScopeOverride::default()
4208                },
4209            )
4210            .is_err());
4211
4212        set_current_connection_id(44_201);
4213        runtime.execute_query("BEGIN").expect("begin");
4214        assert!(runtime.execute_query("SET LOCAL TENANT 'globex'").is_err());
4215        runtime.execute_query("ROLLBACK").expect("rollback");
4216        clear_current_connection_id();
4217    }
4218
4219    #[test]
4220    fn tenant_admin_sql_user_lifecycle_is_tenant_bounded() {
4221        let store = std::sync::Arc::new(AuthStore::new(AuthConfig {
4222            enabled: true,
4223            require_auth: true,
4224            ..AuthConfig::default()
4225        }));
4226        store
4227            .create_user_in_tenant(Some("acme"), "admin", "pw", Role::Admin)
4228            .expect("tenant admin");
4229        let runtime =
4230            RedDBRuntime::with_options(crate::api::RedDBOptions::in_memory()).expect("runtime");
4231        runtime.set_auth_store(std::sync::Arc::clone(&store));
4232        let _identity = ScopedIdentityGuard::install("acme", "admin", Role::Admin);
4233
4234        runtime
4235            .execute_query("CREATE USER acme.alice PASSWORD 'pw' ROLE read")
4236            .expect("same-tenant create");
4237        assert!(runtime
4238            .execute_query("CREATE USER globex.alice PASSWORD 'pw' ROLE read")
4239            .is_err());
4240        assert!(runtime
4241            .execute_query("ALTER USER globex.alice PASSWORD 'owned'")
4242            .is_err());
4243        let cross_tenant_allow = crate::auth::policies::Policy::from_json_str(
4244            r#"{
4245                "id":"tenant-cross-user",
4246                "version":1,
4247                "statements":[{
4248                    "effect":"allow",
4249                    "actions":["user:create","user:password:change"],
4250                    "resources":["user:tenant/globex/*"]
4251                }]
4252            }"#,
4253        )
4254        .expect("cross-tenant policy");
4255        store.put_policy(cross_tenant_allow).expect("put policy");
4256        store
4257            .attach_policy(
4258                crate::auth::store::PrincipalRef::User(crate::auth::UserId::scoped(
4259                    "acme", "admin",
4260                )),
4261                "tenant-cross-user",
4262            )
4263            .expect("attach policy");
4264        assert!(runtime
4265            .execute_query("CREATE USER globex.bob PASSWORD 'pw' ROLE read")
4266            .is_err());
4267        assert!(store.get_user(Some("globex"), "alice").is_none());
4268        assert!(store.get_user(Some("globex"), "bob").is_none());
4269    }
4270
4271    #[test]
4272    fn slow_log_redacts_user_password_statements() {
4273        for sql in [
4274            "CREATE USER alice PASSWORD 'super-secret' ROLE admin",
4275            "CREATE USER acme.alice PASSWORD 'super-secret' ROLE read",
4276            "ALTER USER alice PASSWORD 'super-secret'",
4277            "AlTeR UsEr acme.alice PaSsWoRd 'super-secret'",
4278            "EXPLAIN CREATE USER alice PASSWORD 'super-secret' ROLE read",
4279            "EXPLAIN ALTER USER alice PASSWORD 'super-secret'",
4280            "ALTER USER alice PASSWORD 'unterminated",
4281        ] {
4282            let redacted = redact_sql_for_slow_log(sql);
4283            assert!(!redacted.contains("super-secret"), "leaked from {sql}");
4284            assert!(!redacted.contains("unterminated"), "leaked from {sql}");
4285        }
4286        assert_eq!(
4287            redact_sql_for_slow_log("SELECT password FROM accounts"),
4288            "SELECT password FROM accounts"
4289        );
4290    }
4291
4292    #[test]
4293    fn nested_reads_need_select_on_the_inner_table() {
4294        use crate::auth::privileges::{Action, GrantPrincipal, Resource};
4295        use crate::auth::UserId;
4296        let (runtime, store) = runtime_with_store("ro", Role::Read);
4297        runtime
4298            .execute_query("CREATE TABLE gate_open (a INTEGER)")
4299            .expect("create");
4300        runtime
4301            .execute_query("CREATE TABLE gate_closed (b INTEGER)")
4302            .expect("create");
4303        // One explicit grant switches the legacy fallback off, so `ro`
4304        // may read exactly `gate_open`.
4305        store
4306            .grant(
4307                &UserId::from_parts(None, "admin"),
4308                Role::Admin,
4309                GrantPrincipal::User(UserId::from_parts(None, "ro")),
4310                Resource::table_from_name("gate_open"),
4311                vec![Action::Select],
4312                false,
4313                None,
4314            )
4315            .expect("grant");
4316        let _identity = IdentityGuard::install("ro", Role::Read);
4317        runtime
4318            .execute_query("SELECT * FROM (SELECT a FROM gate_open) s")
4319            .expect("derived table over a granted table is fine");
4320        // The outer statement names only the synthetic subquery table, so
4321        // the inner table used to be read with no check at all.
4322        for sql in [
4323            "SELECT * FROM (SELECT b FROM gate_closed) s",
4324            "SELECT a FROM gate_open WHERE a IN (SELECT b FROM gate_closed)",
4325        ] {
4326            let err = runtime
4327                .execute_query(sql)
4328                .expect_err("nested read of an ungranted table must be refused");
4329            assert!(
4330                err.to_string().contains("permission denied"),
4331                "{sql}: unexpected error: {err}"
4332            );
4333        }
4334    }
4335
4336    #[test]
4337    fn policy_listings_are_self_or_policy_management() {
4338        let (runtime, store) = runtime_with_store("ro", Role::Read);
4339        store.create_user("other", "pw", Role::Read).expect("user");
4340        let _identity = IdentityGuard::install("ro", Role::Read);
4341        runtime
4342            .execute_query("SHOW EFFECTIVE PERMISSIONS FOR ro")
4343            .expect("a principal may inspect its own permissions");
4344        let err = runtime
4345            .execute_query("SHOW EFFECTIVE PERMISSIONS FOR other")
4346            .expect_err("inspecting another principal is policy management");
4347        assert!(
4348            err.to_string().contains("permission denied"),
4349            "unexpected error: {err}"
4350        );
4351    }
4352
4353    #[test]
4354    fn secret_columns_stay_masked_for_readers_without_secret_read() {
4355        use reddb_types::Value;
4356        let (runtime, store) = runtime_with_store("ro", Role::Read);
4357        store.create_user("root", "pw", Role::Admin).expect("admin");
4358        store.ensure_vault_secret_key();
4359        runtime
4360            .execute_query(
4361                "INSERT INTO creds_gate (name, token) VALUES ('stripe', SECRET('sk_live_abc'))",
4362            )
4363            .expect("insert");
4364
4365        let token_for = |username: &str, role: Role| {
4366            let _identity = IdentityGuard::install(username, role);
4367            let result = runtime
4368                .execute_query("SELECT name, token FROM creds_gate")
4369                .expect("select");
4370            result.result.records[0]
4371                .get("token")
4372                .cloned()
4373                .expect("token column")
4374        };
4375        // Ordinary SELECT stays masked for every role. Plaintext retrieval
4376        // requires an explicit reveal operation.
4377        assert!(
4378            matches!(token_for("ro", Role::Read), Value::Secret(_)),
4379            "a read-role principal must see the column masked"
4380        );
4381        assert!(matches!(token_for("root", Role::Admin), Value::Secret(_)));
4382    }
4383
4384    #[test]
4385    fn store_wide_operations_need_the_admin_tier() {
4386        let runtime = runtime_with_user("bob", Role::Write);
4387        let _identity = IdentityGuard::install("bob", Role::Write);
4388        // These fell through the gate's wildcard arm, so a write-role
4389        // principal could scrub or vacuum the store.
4390        for sql in ["VACUUM", "SCRUB"] {
4391            let err = runtime
4392                .execute_query(sql)
4393                .expect_err("store-wide operation must be refused for a non-admin");
4394            assert!(
4395                err.to_string().contains("permission denied"),
4396                "{sql}: unexpected error: {err}"
4397            );
4398        }
4399    }
4400
4401    #[test]
4402    fn explain_needs_the_privilege_of_the_statement_it_plans() {
4403        let runtime = runtime_with_user("ro", Role::Read);
4404        let _identity = IdentityGuard::install("ro", Role::Read);
4405        runtime
4406            .execute_query("EXPLAIN SELECT 1 AS one")
4407            .expect("planning a read is a read");
4408        // EXPLAIN used to bypass the gate entirely, so a read-only
4409        // principal could plan any write and read the schema back.
4410        let err = runtime
4411            .execute_query("EXPLAIN INSERT INTO gate_t (a) VALUES (1)")
4412            .expect_err("planning a write needs the write privilege");
4413        assert!(
4414            err.to_string().contains("permission denied"),
4415            "unexpected EXPLAIN error: {err}"
4416        );
4417    }
4418
4419    #[test]
4420    fn copy_from_a_server_path_is_admin_only() {
4421        let runtime = runtime_with_user("bob", Role::Write);
4422        let _identity = IdentityGuard::install("bob", Role::Write);
4423        // `COPY … FROM '<path>'` opens a file on the *server*, so a
4424        // write-role principal used to be able to read /proc/self/environ
4425        // (admin token, vault certificate, cloud keys) into a table.
4426        let err = runtime
4427            .execute_query("COPY leak FROM '/etc/hostname'")
4428            .expect_err("COPY FROM must be refused for a non-admin");
4429        assert!(
4430            err.to_string().contains("permission denied"),
4431            "unexpected COPY error: {err}"
4432        );
4433    }
4434
4435    #[test]
4436    fn vcs_history_rewrites_are_admin_only() {
4437        let runtime = runtime_with_user("bob", Role::Write);
4438        let _identity = IdentityGuard::install("bob", Role::Write);
4439        // VCS commands were dispatched before the statement frame was built,
4440        // so `RESET HARD TO` ran with no privilege check at all.
4441        let err = runtime
4442            .execute_query("RESET HARD TO 'deadbeef'")
4443            .expect_err("RESET must be refused for a non-admin");
4444        assert!(
4445            err.to_string().contains("permission denied"),
4446            "unexpected RESET error: {err}"
4447        );
4448    }
4449
4450    #[test]
4451    fn ai_provider_config_writes_are_admin_only() {
4452        let runtime = runtime_with_user("bob", Role::Write);
4453        let _identity = IdentityGuard::install("bob", Role::Write);
4454        // This namespace decides which vault secret is sent, as a bearer, to
4455        // which host — the exfiltration primitive behind the SET CONFIG gap.
4456        let err = runtime
4457            .execute_query(
4458                "SET CONFIG ai.providers.openai.tokens.default.secret_ref = 'red.secret.aes_key'",
4459            )
4460            .expect_err("AI provider config must be refused for a non-admin");
4461        assert!(
4462            err.to_string().contains("permission denied"),
4463            "unexpected SET CONFIG error: {err}"
4464        );
4465    }
4466
4467    #[test]
4468    fn engine_internal_vault_keys_are_not_writable_through_set_secret() {
4469        let runtime = runtime_with_user("root", Role::Admin);
4470        let _identity = IdentityGuard::install("root", Role::Admin);
4471        // Even an admin must not overwrite the IAM policy blob (an allow-all
4472        // policy would survive a restart) or the `SECRET()` master key
4473        // (rotating it destroys every encrypted column value).
4474        for key in [
4475            "red.iam.policies",
4476            "red.iam.attachments.users",
4477            "red.secret.aes_key",
4478        ] {
4479            let err = runtime
4480                .execute_query(&format!("SET SECRET {key} = 'x'"))
4481                .expect_err("engine-internal vault key must be refused");
4482            assert!(
4483                err.to_string().contains("engine-internal"),
4484                "unexpected error for {key}: {err}"
4485            );
4486        }
4487    }
4488}