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