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