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