Skip to main content

reddb_server/runtime/
impl_core.rs

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