Skip to main content

reddb_server/runtime/
statement_frame.rs

1use std::cell::RefCell;
2use std::collections::HashSet;
3use std::rc::Rc;
4use std::sync::Arc;
5
6use super::impl_core::{
7    collections_referenced, current_auth_identity, current_connection_id, current_tenant,
8    has_with_prefix, intent_lock_modes_for, peek_top_level_as_of_with_table,
9    query_has_volatile_builtin, query_is_ask_statement, ConfigSnapshotGuard, CurrentSnapshotGuard,
10    KvStoreGuard, SecretStoreGuard, SnapshotContext, TxLocalTenantGuard,
11};
12use super::{RedDBRuntime, RuntimeQueryResult, RuntimeResultCacheEntry};
13use crate::api::{RedDBError, RedDBResult};
14use crate::auth::Role;
15use crate::storage::query::ast::QueryExpr;
16use crate::storage::query::modes::{detect_mode, parse_multi, QueryMode};
17use crate::storage::transaction::snapshot::{Snapshot, Xid};
18
19/// Coarse privilege classification for a statement, computed once at
20/// frame-build time from the SQL text. Mirrors the three-role auth
21/// model (`Role::Read < Role::Write < Role::Admin`) so the frame can
22/// answer "can this identity run this statement?" without re-walking
23/// the parsed `QueryExpr` at every call site.
24///
25/// `None` means the statement does not touch the privilege gate at
26/// all (transaction control, SET, SHOW). Such statements must remain
27/// runnable under any authenticated identity.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub(crate) enum Privilege {
30    /// Read-only data access (SELECT, EXPLAIN, SHOW). Satisfied by
31    /// any role from `Role::Read` upward.
32    Read,
33    /// Mutation of user data or schema author DDL (INSERT, UPDATE,
34    /// DELETE, CREATE/ALTER/DROP TABLE, CREATE MIGRATION). Requires
35    /// at least `Role::Write`.
36    Write,
37    /// Authority statements — GRANT, REVOKE, ALTER USER, APPLY /
38    /// ROLLBACK MIGRATION, IAM policy mutation. Requires `Role::Admin`.
39    Admin,
40    /// Statement does not consult the privilege gate (BEGIN, COMMIT,
41    /// ROLLBACK, SET, SHOW with no data exposure). Always permitted
42    /// for any authenticated identity.
43    None,
44}
45
46impl Privilege {
47    /// `true` iff `role` is sufficient to execute a statement carrying
48    /// this required privilege. Encodes the standard `Read ⊆ Write ⊆
49    /// Admin` containment used by the auth fallback path.
50    pub(crate) fn is_satisfied_by(self, role: Role) -> bool {
51        match self {
52            Self::None => true,
53            Self::Read => role.can_read(),
54            Self::Write => role.can_write(),
55            Self::Admin => role.can_admin(),
56        }
57    }
58}
59
60/// Coarse lock intent for a statement, computed once at frame-build
61/// time. Maps onto the storage-layer's `LockMode` matrix downstream
62/// but stays decoupled here so the runtime can answer "does this
63/// statement need the lock manager at all?" without a `use storage::`
64/// at every call site.
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub(crate) enum LockIntent {
67    /// No collection-level lock needed (transaction control, SET,
68    /// SHOW, EXPLAIN). The lock-acquisition path can short-circuit.
69    None,
70    /// Reader-style intent: SELECT, joins, graph / queue / search
71    /// reads. Maps to `(IS, IS)` at the storage layer.
72    Shared,
73    /// Writer- or DDL-style intent: INSERT/UPDATE/DELETE (`(IX, IX)`)
74    /// and CREATE/ALTER/DROP (`(IX, X)`). Both are surfaced as
75    /// `Exclusive` at this granularity — call sites that need the
76    /// finer distinction still consult `intent_lock_modes_for`.
77    Exclusive,
78}
79
80/// Small, stable Interface that *represents* a read statement's
81/// execution context. Every read caller that needs to know "under
82/// what scope / identity / snapshot am I running, and is there an
83/// AS OF floor in effect?" consults this trait — never the
84/// underlying thread-locals or runtime fields directly.
85///
86/// The deletion test: removing this trait would force the four
87/// concerns it exposes back into ad-hoc lookups at every read
88/// callsite (`current_tenant()`, `current_auth_identity()`,
89/// `capture_current_snapshot()`, AS OF re-parsing). The trait
90/// concentrates them in one place so future changes (per-statement
91/// logging, audit, scope policy) have a single seam to extend.
92pub(crate) trait ReadFrame {
93    /// Effective tenant scope for the statement after WITHIN /
94    /// SET LOCAL TENANT / SET TENANT resolution. `None` means
95    /// "no tenant bound" (RLS deny-default applies).
96    fn effective_scope(&self) -> Option<&str>;
97
98    /// Authenticated identity observed at frame-build time, if any.
99    /// Returns `(username, role)` so callers can render audit lines
100    /// or feed RLS policy lookups without re-reading thread-locals.
101    fn identity(&self) -> Option<(&str, Role)>;
102
103    /// MVCC snapshot the statement reads against. For autocommit
104    /// this is a fresh snapshot; inside an active transaction it
105    /// is the txn's snapshot; under AS OF it is the resolved
106    /// historical xid.
107    fn snapshot(&self) -> &Snapshot;
108
109    /// AS OF xid floor when AS OF was applied for this statement,
110    /// `None` for live reads. Useful for downstream callers that
111    /// want to gate behaviour on historical-read mode without
112    /// re-parsing the query.
113    fn as_of_floor(&self) -> Option<Xid>;
114
115    /// Stable result-cache key for the statement (already mixes
116    /// effective tenant + identity).
117    fn cache_key(&self) -> &str;
118
119    /// Whether the statement is safe to serve from / populate the
120    /// result cache. Combines two underlying signals:
121    ///
122    ///   * the query does not call a volatile builtin (e.g. `NOW()`,
123    ///     `RANDOM()`, `UUID()`), which would change between calls,
124    ///   * the connection is not inside an active transaction with
125    ///     uncommitted writes that other readers shouldn't observe.
126    ///
127    /// SELECT cache callsites (read + write) consult this method
128    /// instead of re-deriving safety from globals or poking the
129    /// frame's private fields. Removing it would force every cache
130    /// callsite to re-run `query_has_volatile_builtin` plus
131    /// `result_cache_safe(conn_id)` inline.
132    fn should_cache_result(&self) -> bool;
133
134    /// Coarse privilege class the statement requires, computed once
135    /// at frame-build time from the SQL prefix. Read/write dispatch
136    /// sites consult this instead of re-classifying the parsed
137    /// `QueryExpr` inline at every callsite.
138    ///
139    /// Removing this method would force every privilege gate to
140    /// recompute the (action, resource) classification from the
141    /// parsed expression and re-check the role hierarchy inline.
142    fn required_privilege(&self) -> Privilege;
143
144    /// Coarse collection-level lock intent the statement implies.
145    /// `None` lets the lock-acquisition path short-circuit without
146    /// touching the lock manager.
147    ///
148    /// Removing this method would force the lock-acquisition path
149    /// to always invoke `intent_lock_modes_for` (which itself walks
150    /// the parsed expression) even for transaction-control / SET /
151    /// SHOW statements that need no collection lock at all.
152    fn lock_intent(&self) -> LockIntent;
153
154    /// Set of collection ids the calling identity is allowed to
155    /// observe under the active `(tenant, role)` scope. Computed once
156    /// at frame-build time via the `AuthStore` visible-collections
157    /// cache (see `auth::scope_cache`) and used by `AuthorizedSearch`
158    /// to pre-filter SEARCH SIMILAR / SEARCH CONTEXT candidate sets
159    /// before any similarity score is computed (issue #119).
160    ///
161    /// `None` means the frame was built without an auth store wired —
162    /// embedded / single-tenant tests run that way. AI search call
163    /// sites refuse to proceed with `None`, which is the deny-default
164    /// the issue requires; pure SELECT paths fall back to the existing
165    /// per-row RLS gate.
166    fn visible_collections(&self) -> Option<&std::collections::HashSet<String>>;
167}
168
169/// Cheap first-word classification of a SQL statement, used at
170/// frame-build time to derive `Privilege` + `LockIntent` without
171/// re-parsing the query. Matches the keywords that the legacy
172/// inline checks in `RedDBRuntime::check_query_privilege` and
173/// `intent_lock_modes_for` already key on.
174fn statement_kind(query: &str) -> &'static str {
175    let trimmed = query.trim_start();
176    // Skip a leading line / block comment so the classifier doesn't
177    // misread `/* ... */ SELECT ...` as an unknown statement.
178    let trimmed = if let Some(rest) = trimmed.strip_prefix("--") {
179        rest.split_once('\n')
180            .map(|(_, r)| r)
181            .unwrap_or("")
182            .trim_start()
183    } else {
184        trimmed
185    };
186    let mut tokens = trimmed.split(|c: char| c.is_whitespace() || c == '(' || c == ';');
187    let first = tokens.next().unwrap_or("");
188    let second = tokens.next().unwrap_or("");
189    if first.eq_ignore_ascii_case("KV") {
190        if second.eq_ignore_ascii_case("GET")
191            || second.eq_ignore_ascii_case("LIST")
192            || second.eq_ignore_ascii_case("WATCH")
193        {
194            return "read";
195        }
196        return "write";
197    }
198    if first.eq_ignore_ascii_case("VAULT") {
199        if second.eq_ignore_ascii_case("LIST")
200            || second.eq_ignore_ascii_case("WATCH")
201            || second.eq_ignore_ascii_case("HISTORY")
202        {
203            return "read";
204        }
205        return "write";
206    }
207    // ASCII-uppercase compare without allocating: SQL keywords are ASCII.
208    let mut buf = [0u8; 16];
209    let bytes = first.as_bytes();
210    let n = bytes.len().min(buf.len());
211    for i in 0..n {
212        buf[i] = bytes[i].to_ascii_uppercase();
213    }
214    match &buf[..n] {
215        b"SELECT" | b"WITH" | b"SHOW" | b"EXPLAIN" | b"DESCRIBE" | b"DESC" | b"RANK"
216        | b"APPROX" | b"APPROXIMATE" | b"ZRANK" | b"ZRANGE" | b"LIST" | b"WATCH" | b"GET"
217        | b"HISTORY" => "read",
218        b"INSERT" | b"UPDATE" | b"DELETE" | b"UPSERT" | b"MERGE" | b"COPY" | b"TRUNCATE" => "write",
219        b"CREATE" | b"ALTER" | b"DROP" | b"PROMOTE" | b"REINDEX" | b"VACUUM" | b"ANALYZE" => "ddl",
220        b"GRANT" | b"REVOKE" => "admin",
221        b"BEGIN" | b"START" | b"COMMIT" | b"ROLLBACK" | b"SAVEPOINT" | b"RELEASE" | b"END"
222        | b"SET" | b"RESET" | b"PREPARE" | b"EXECUTE" | b"DEALLOCATE" | b"USE" => "control",
223        b"PUT" | b"INCR" | b"DECR" | b"ADD" | b"ROTATE" | b"PURGE" | b"UNSEAL" | b"INVALIDATE" => {
224            "write"
225        }
226        _ => "unknown",
227    }
228}
229
230fn classify_privilege(query: &str) -> Privilege {
231    match statement_kind(query) {
232        "read" => Privilege::Read,
233        "write" => Privilege::Write,
234        // DDL is gated at `Role::Write` in the legacy fallback (see
235        // `RedDBRuntime::check_query_privilege` for CreateTable et al.),
236        // so it classifies as Write here. APPLY / ROLLBACK MIGRATION and
237        // GRANT / REVOKE upgrade to Admin via finer checks at the call
238        // site — the frame surfaces only the coarse class.
239        "ddl" => Privilege::Write,
240        "admin" => Privilege::Admin,
241        _ => Privilege::None,
242    }
243}
244
245fn classify_lock_intent(query: &str) -> LockIntent {
246    match statement_kind(query) {
247        "read" => LockIntent::Shared,
248        "write" | "ddl" => LockIntent::Exclusive,
249        _ => LockIntent::None,
250    }
251}
252
253pub(super) struct StatementExecutionFrame {
254    tx_local_tenant: Option<Option<String>>,
255    snapshot: Snapshot,
256    own_xids: HashSet<Xid>,
257    serializable_reader: Option<Xid>,
258    cache_key: String,
259    is_volatile_query: bool,
260    cache_safe: bool,
261    /// Effective tenant captured at frame-build time after WITHIN /
262    /// SET LOCAL TENANT / SET TENANT resolution. Stored on the frame
263    /// so the `ReadFrame` Interface can return a borrow without
264    /// re-touching the thread-local stack.
265    effective_scope: Option<String>,
266    /// Auth identity captured at frame-build time. `None` for
267    /// embedded / anonymous callers.
268    identity: Option<(String, Role)>,
269    /// `Some(xid)` when AS OF resolved to a historical xid; `None`
270    /// for live reads.
271    as_of_floor: Option<Xid>,
272    /// True when the statement snapshot can require tuple versions that
273    /// current secondary indexes no longer contain.
274    requires_index_fallback: bool,
275    /// Privilege class required by the statement, derived from the
276    /// SQL text at frame-build time. Read/write dispatch sites
277    /// consult this instead of re-classifying the parsed expression.
278    required_privilege: Privilege,
279    /// Collection-level lock intent the statement implies. The
280    /// lock-acquisition path short-circuits when this is `None`.
281    lock_intent: LockIntent,
282    /// Set of collection ids the active `(tenant, role)` scope is
283    /// allowed to observe. Computed at frame-build time via the
284    /// `AuthStore` visibility cache and consumed by `AuthorizedSearch`
285    /// to gate SEARCH SIMILAR / SEARCH CONTEXT candidate sets before
286    /// scoring (issue #119). `None` when no auth store is wired
287    /// (embedded test mode) — AI search refuses on `None`.
288    visible_collections: Option<HashSet<String>>,
289    /// Per-owner buffer arena for query-result row chunks (#885). Owned
290    /// by the frame because the frame already owns the query lifecycle;
291    /// lent to the row-streaming path (`execute_runtime_table_query_in`)
292    /// so chunk buffers are reused across the statement's chunk-fetches
293    /// instead of allocated fresh per chunk. Reclaimed when the frame
294    /// drops at statement end — no `thread_local!` scratch, which would
295    /// be unsound under tokio's work-stealing runtime.
296    row_arena: Rc<RefCell<super::query_exec::RowBufferArena>>,
297}
298
299pub(super) struct StatementFrameGuards {
300    _tx_local_guard: TxLocalTenantGuard,
301    _config_snapshot_guard: ConfigSnapshotGuard,
302    _secret_store_guard: SecretStoreGuard,
303    _kv_store_guard: KvStoreGuard,
304    _snapshot_guard: CurrentSnapshotGuard,
305}
306
307pub(super) struct PreparedStatement {
308    pub(super) expr: QueryExpr,
309    pub(super) mode: QueryMode,
310}
311
312impl StatementExecutionFrame {
313    pub(super) fn build(runtime: &RedDBRuntime, query: &str) -> RedDBResult<Self> {
314        let conn_id = current_connection_id();
315        let tx_local_tenant = runtime.inner.tx_local_tenants.read().get(&conn_id).cloned();
316        let tx_context = runtime.inner.tx_contexts.read().get(&conn_id).cloned();
317        let own_xids = own_transaction_xids(tx_context.as_ref());
318        let serializable_reader = tx_context
319            .as_ref()
320            .filter(|ctx| {
321                ctx.isolation == crate::storage::transaction::IsolationLevel::Serializable
322            })
323            .map(|ctx| ctx.xid);
324        let (snapshot, as_of_floor) = runtime.statement_snapshot(query)?;
325        let requires_index_fallback = as_of_floor.is_some() || tx_context.is_some();
326        let cache_key = result_cache_key(query);
327        let is_volatile_query = query_has_volatile_builtin(query) || query_is_ask_statement(query);
328        let cache_safe = runtime.result_cache_safe(conn_id);
329        // Capture identity + effective scope under the same
330        // thread-local view that the cache key was built from, so
331        // the Interface and the cache key agree on what "this
332        // statement" means.
333        let effective_scope = current_tenant();
334        let identity = current_auth_identity();
335
336        // Coarse classification of the statement, computed once from
337        // the SQL prefix so downstream callers don't re-derive it
338        // from the parsed `QueryExpr` at every privilege / lock site.
339        let required_privilege = classify_privilege(query);
340        let lock_intent = classify_lock_intent(query);
341
342        // Issue #119: resolve the visible-collections set for the
343        // active (tenant, role) scope. Only meaningful when an auth
344        // store is wired *and* an identity was captured — embedded
345        // anonymous callers fall back to `None`, and AI search call
346        // sites refuse on `None`.
347        let visible_collections = match (runtime.inner.auth_store.read().clone(), identity.as_ref())
348        {
349            (Some(store), Some((principal, role))) => {
350                let collections = runtime.inner.db.store().list_collections();
351                Some(store.visible_collections_for_scope(
352                    effective_scope.as_deref(),
353                    *role,
354                    principal,
355                    &collections,
356                ))
357            }
358            _ => None,
359        };
360
361        Ok(Self {
362            tx_local_tenant,
363            snapshot,
364            own_xids,
365            serializable_reader,
366            cache_key,
367            is_volatile_query,
368            cache_safe,
369            effective_scope,
370            identity,
371            as_of_floor,
372            requires_index_fallback,
373            required_privilege,
374            lock_intent,
375            visible_collections,
376            row_arena: Rc::new(RefCell::new(super::query_exec::RowBufferArena::new())),
377        })
378    }
379
380    /// Lend the frame's per-owner row-buffer arena (#885) to the
381    /// row-streaming path. Returns a cloned `Rc` handle; the frame remains
382    /// the owner and the arena is reclaimed when the frame drops at
383    /// statement end.
384    pub(super) fn row_arena(&self) -> Rc<RefCell<super::query_exec::RowBufferArena>> {
385        Rc::clone(&self.row_arena)
386    }
387
388    pub(super) fn install(&self, runtime: &RedDBRuntime) -> StatementFrameGuards {
389        StatementFrameGuards {
390            _tx_local_guard: TxLocalTenantGuard::install(self.tx_local_tenant.clone()),
391            _config_snapshot_guard: ConfigSnapshotGuard::install(
392                Arc::clone(&runtime.inner.db),
393                runtime.inner.auth_store.read().clone(),
394            ),
395            _secret_store_guard: SecretStoreGuard::install(runtime.inner.auth_store.read().clone()),
396            _kv_store_guard: KvStoreGuard::install(runtime.inner.auth_store.read().clone()),
397            _snapshot_guard: CurrentSnapshotGuard::install(SnapshotContext {
398                snapshot: self.snapshot.clone(),
399                manager: Arc::clone(&runtime.inner.snapshot_manager),
400                own_xids: self.own_xids.clone(),
401                requires_index_fallback: self.requires_index_fallback,
402                serializable_reader: self.serializable_reader,
403            }),
404        }
405    }
406
407    pub(super) fn cache_key(&self) -> &str {
408        &self.cache_key
409    }
410
411    pub(super) fn can_read_result_cache(&self) -> bool {
412        // Delegates to the `ReadFrame` Interface so the volatile +
413        // active-tx safety decision lives in exactly one place.
414        <Self as ReadFrame>::should_cache_result(self)
415    }
416
417    pub(super) fn should_write_result_cache(&self, result: &RuntimeQueryResult) -> bool {
418        // Cache-safety (volatile builtin, active-tx writes) comes from
419        // the Interface; the rest are write-side payload heuristics
420        // (statement shape, result size) that aren't part of the
421        // safety contract.
422        <Self as ReadFrame>::should_cache_result(self)
423            && result.statement_type == "select"
424            && result.engine != "vault"
425            && result.engine != "runtime-rank"
426            // `QUEUE READ` is a stateful read: a delayed message
427            // (issue #722) becomes deliverable over time without a
428            // producer push to invalidate the cache, so a cached empty
429            // result would hide it. Skip caching entirely.
430            && result.statement != "queue_group_read"
431            // SCRUB is a stateful read too: the background form advances
432            // a pacing cursor per tick (a cached tick would replay the
433            // summary and never advance), and even a full pass must
434            // re-read the file to see new corruption. Never cache.
435            && result.engine != "runtime-scrub"
436            && result.result.pre_serialized_json.is_none()
437            // Graph-analytics TVF output (issue #802) is deterministic and
438            // expensive to recompute, so it is cached at any row count. The
439            // ≤5-row heuristic only bounds payload size for ordinary SELECTs.
440            && (is_graph_tvf_engine(result.engine) || result.result.records.len() <= 5)
441    }
442
443    pub(super) fn read_result_cache(&self, runtime: &RedDBRuntime) -> Option<RuntimeQueryResult> {
444        if self.can_read_result_cache() {
445            runtime.get_result_cache_entry(self.cache_key())
446        } else {
447            None
448        }
449    }
450
451    pub(super) fn write_result_cache(
452        &self,
453        runtime: &RedDBRuntime,
454        result: &RuntimeQueryResult,
455        scopes: HashSet<String>,
456    ) {
457        if self.should_write_result_cache(result) {
458            runtime.put_result_cache_entry(
459                self.cache_key(),
460                RuntimeResultCacheEntry {
461                    result: result.clone(),
462                    cached_at: std::time::Instant::now(),
463                    scopes,
464                },
465            );
466        }
467    }
468
469    pub(super) fn prepare_cte(&self, query: &str) -> RedDBResult<Option<QueryExpr>> {
470        // Detected via cheap prefix check so non-CTE queries skip the
471        // full parse here. CTE-bearing queries bypass the plan cache
472        // and result cache (rare workload — perf optimization is a
473        // follow-up). Inlining substitutes every CTE reference with
474        // its body as a subquery in FROM, after which the existing
475        // subquery-in-FROM machinery handles execution. Recursive
476        // CTEs are rejected explicitly until fixpoint execution wires
477        // through the runtime.
478        if !has_with_prefix(query) {
479            return Ok(None);
480        }
481        let parsed = crate::storage::query::parser::parse(query)
482            .map_err(|err| RedDBError::Query(err.to_string()))?;
483        if parsed.with_clause.is_some() {
484            let rewritten = crate::storage::query::executors::inline_ctes(parsed)
485                .map_err(|err| RedDBError::Query(err.to_string()))?;
486            return Ok(Some(rewritten));
487        }
488        // No WITH after parse (the prefix matched something else like
489        // `WITHIN` that already routed elsewhere) — fall through to
490        // the normal path with the original query.
491        Ok(None)
492    }
493
494    pub(super) fn prepare_statement(
495        &self,
496        runtime: &RedDBRuntime,
497        query: &str,
498    ) -> RedDBResult<PreparedStatement> {
499        let mode = detect_mode(query);
500        if matches!(mode, QueryMode::Unknown) {
501            return Err(RedDBError::Query("unable to detect query mode".to_string()));
502        }
503
504        // ── Plan cache: reuse only exact-query ASTs ──
505        //
506        // DML statements (INSERT/UPDATE/DELETE) almost always have unique literal
507        // values, so caching them burns CPU on eviction bookkeeping (Vec::remove(0)
508        // shifts the entire LRU list) with zero hit rate. Skip the cache entirely
509        // Plan cache applies to statements whose shape can be
510        // normalised + rebound (`UPDATE t SET x=? WHERE _entity_id=?`
511        // reuses the same plan across thousands of varying literals).
512        // INSERT is still bypassed — its shape changes per column set
513        // and bulk paths don't go through here anyway.
514        let first_word = query
515            .trim()
516            .split_ascii_whitespace()
517            .next()
518            .unwrap_or("")
519            .to_ascii_uppercase();
520        let is_insert = first_word == "INSERT";
521        // #1370 — volatile queries ($config / $secret resolve mutable runtime
522        // state at execution time) must bypass the plan cache too. A cached
523        // optimized plan drops the live `$config` resolution, so a later
524        // `SET CONFIG` would be ignored and the query would serve a stale value.
525        // Re-parse fresh every time so the resolver runs against current state.
526        let bypass_plan_cache = is_insert || self.is_volatile_query;
527
528        // Fused normalize+extract: one byte-scan produces both the
529        // cache_key AND the literal bindings. Saves a second Lexer
530        // pass over the query text on every cache hit — dominant
531        // cost on tight UPDATE loops that hit the same shape
532        // thousands of times with varying literals.
533        let (cache_key, prescan_binds) = if bypass_plan_cache {
534            (String::new(), Vec::new())
535        } else {
536            crate::storage::query::planner::cache_key::normalize_and_extract(query)
537        };
538
539        let expr = if bypass_plan_cache {
540            // Bypass plan cache for INSERT — shape varies per query.
541            parse_multi(query).map_err(|err| RedDBError::Query(err.to_string()))?
542        } else {
543            // ── Hot path: read lock only (no writer serialization on cache hits) ──
544            //
545            // peek() is a non-mutating probe: no LRU promotion, no touch().
546            // This lets concurrent readers proceed without blocking each other.
547            // On hit we bind literals if needed and return immediately.
548            // Only on miss do we drop to a write lock to parse + insert.
549            let hit = {
550                let plan_cache = runtime.inner.query_cache.read();
551                plan_cache.peek(&cache_key).map(|cached| {
552                    let parameter_count = cached.parameter_count;
553                    let optimized = cached.plan.optimized.clone();
554                    let exact_query = cached.exact_query.clone();
555                    (parameter_count, optimized, exact_query)
556                })
557            };
558
559            if let Some((parameter_count, optimized, exact_query)) = hit {
560                if parameter_count > 0 {
561                    // Shape hit: use the binds extracted during normalise.
562                    let shape_binds = prescan_binds.clone();
563                    if let Some(bound) =
564                        crate::storage::query::planner::shape::bind_parameterized_query(
565                            &optimized,
566                            &shape_binds,
567                            parameter_count,
568                        )
569                    {
570                        bound
571                    } else if exact_query.as_deref() == Some(query) {
572                        // Bind failed but exact query matches — use as-is.
573                        optimized
574                    } else {
575                        // Bind failed and literals differ: re-parse fresh.
576                        parse_multi(query).map_err(|err| RedDBError::Query(err.to_string()))?
577                    }
578                } else {
579                    // No parameters means either there truly are no literals,
580                    // or this statement type does not participate in shape
581                    // parameterization (for example graph/queue commands).
582                    // Reusing a normalized-cache hit across a different exact
583                    // query can therefore leak stale literals into execution.
584                    if exact_query.as_deref() == Some(query) {
585                        optimized
586                    } else {
587                        parse_multi(query).map_err(|err| RedDBError::Query(err.to_string()))?
588                    }
589                }
590            } else {
591                // Cache miss — parse, parameterize, store.
592                let parsed =
593                    parse_multi(query).map_err(|err| RedDBError::Query(err.to_string()))?;
594                let (cached_expr, parameter_count) = if let Some(prepared) =
595                    crate::storage::query::planner::shape::parameterize_query_expr(&parsed)
596                {
597                    (prepared.shape, prepared.parameter_count)
598                } else {
599                    (parsed.clone(), 0)
600                };
601                {
602                    let mut pc = runtime.inner.query_cache.write();
603                    let plan = crate::storage::query::planner::QueryPlan::new(
604                        parsed.clone(),
605                        cached_expr,
606                        Default::default(),
607                    );
608                    pc.insert(
609                        cache_key.clone(),
610                        crate::storage::query::planner::CachedPlan::new(plan)
611                            .with_shape_key(cache_key.clone())
612                            .with_exact_query(query.to_string())
613                            .with_parameter_count(parameter_count),
614                    );
615                }
616                parsed
617            }
618        };
619
620        // Phase 5 PG parity: substitute any registered view name that
621        // appears in the expression with its stored body. Runs after
622        // parse and before dispatch so the SQL entrypoint gets the
623        // same view resolution `execute_query_expr` already does.
624        let expr = runtime.rewrite_view_refs(expr);
625
626        Ok(PreparedStatement { expr, mode })
627    }
628
629    pub(super) fn check_query_privilege(
630        &self,
631        runtime: &RedDBRuntime,
632        expr: &QueryExpr,
633    ) -> RedDBResult<()> {
634        // Frame-level coarse gate. We consult `required_privilege()`
635        // (computed once at frame-build) against the captured identity
636        // before the deep grant engine walks the parsed expression.
637        // The coarse gate cannot ALLOW anything the grant engine would
638        // deny — it only short-circuits the obvious "Role::Read tries
639        // INSERT" case so a downstream caller never has to redo this
640        // check inline. `Privilege::None` (transaction control / SET /
641        // SHOW) flows through unchanged; the grant engine treats those
642        // as bypass too.
643        if let Some((username, role)) = <Self as ReadFrame>::identity(self) {
644            let needed = <Self as ReadFrame>::required_privilege(self);
645            if !needed.is_satisfied_by(role) {
646                // Issue #205 — when the deep grant engine *also*
647                // denies, we treat this as an ordinary permission
648                // failure. But when an Admin-only statement reaches
649                // this gate without an auth_store wired (so the deep
650                // engine can't double-check), the coarse rejection is
651                // the only line of defence — emit an OperatorEvent so
652                // the operator notices an Admin-class statement was
653                // attempted with insufficient role.
654                if matches!(needed, Privilege::Admin) && runtime.inner.auth_store.read().is_none() {
655                    crate::telemetry::operator_event::OperatorEvent::AuthBypass {
656                        principal: username.to_string(),
657                        resource: format!("statement requiring {needed:?}"),
658                        detail: format!(
659                            "auth_store not wired; coarse gate is sole defence (role={role:?})"
660                        ),
661                    }
662                    .emit_global();
663                }
664                return Err(RedDBError::Query(format!(
665                    "permission denied: principal=`{username}` role=`{role:?}` lacks {needed:?} privilege"
666                )));
667            }
668        }
669        runtime
670            .check_query_privilege(expr)
671            .map_err(|err| RedDBError::Query(format!("permission denied: {err}")))
672    }
673
674    pub(super) fn prepare_dispatch(
675        &self,
676        runtime: &RedDBRuntime,
677        expr: &QueryExpr,
678    ) -> RedDBResult<Option<crate::runtime::locking::LockerGuard>> {
679        runtime.validate_model_operations_before_auth(expr)?;
680        self.check_query_privilege(runtime, expr)?;
681        Ok(self.acquire_intent_locks(runtime, expr))
682    }
683
684    pub(super) fn acquire_intent_locks(
685        &self,
686        runtime: &RedDBRuntime,
687        expr: &QueryExpr,
688    ) -> Option<crate::runtime::locking::LockerGuard> {
689        if !runtime.config_bool("concurrency.locking.enabled", true) {
690            return None;
691        }
692        // Frame-level short-circuit: if the statement carries no lock
693        // intent (transaction control, SET, SHOW), skip the lock
694        // manager entirely instead of letting `intent_lock_modes_for`
695        // walk the parsed expression to reach the same conclusion.
696        if matches!(<Self as ReadFrame>::lock_intent(self), LockIntent::None) {
697            return None;
698        }
699        intent_lock_modes_for(expr).map(|(global_mode, coll_mode)| {
700            let mut guard =
701                crate::runtime::locking::LockerGuard::new(runtime.inner.lock_manager.clone());
702            let _ = guard.acquire(crate::runtime::locking::Resource::Global, global_mode);
703            for collection in collections_referenced(expr) {
704                let _ = guard.acquire(
705                    crate::runtime::locking::Resource::Collection(collection),
706                    coll_mode,
707                );
708            }
709            guard
710        })
711    }
712}
713
714impl ReadFrame for StatementExecutionFrame {
715    fn effective_scope(&self) -> Option<&str> {
716        self.effective_scope.as_deref()
717    }
718
719    fn identity(&self) -> Option<(&str, Role)> {
720        self.identity.as_ref().map(|(u, r)| (u.as_str(), *r))
721    }
722
723    fn snapshot(&self) -> &Snapshot {
724        &self.snapshot
725    }
726
727    fn as_of_floor(&self) -> Option<Xid> {
728        self.as_of_floor
729    }
730
731    fn cache_key(&self) -> &str {
732        &self.cache_key
733    }
734
735    fn should_cache_result(&self) -> bool {
736        !self.is_volatile_query && self.cache_safe
737    }
738
739    fn required_privilege(&self) -> Privilege {
740        self.required_privilege
741    }
742
743    fn lock_intent(&self) -> LockIntent {
744        self.lock_intent
745    }
746
747    fn visible_collections(&self) -> Option<&HashSet<String>> {
748        self.visible_collections.as_ref()
749    }
750}
751
752/// Lightweight `ReadFrame` carrier used by AI command entry points
753/// (`SEARCH SIMILAR`, `SEARCH CONTEXT`, `ASK`).
754///
755/// Issue #119 calls this struct `EffectiveScope`. It bundles the
756/// `(tenant, identity, role, visible_collections, snapshot)` tuple so
757/// every AI runtime entry can pass *one* value to `AuthorizedSearch`
758/// instead of re-reading thread-locals at every call site.
759///
760/// Built via `RedDBRuntime::ai_scope()` which sources tenant + identity
761/// from the per-statement thread-locals (identical to how
762/// `StatementExecutionFrame::build` derives them) and resolves
763/// `visible_collections` via the `AuthStore` cache.
764pub struct EffectiveScope {
765    pub(crate) tenant: Option<String>,
766    pub(crate) identity: Option<(String, Role)>,
767    pub(crate) snapshot: Snapshot,
768    pub(crate) visible_collections: Option<HashSet<String>>,
769}
770
771impl EffectiveScope {
772    /// Capability check used by the AI runtime (`runtime/ai/ner.rs`)
773    /// to gate LLM-backed NER calls behind `ai:ner:read`.
774    ///
775    /// Placeholder for now: always returns `false`. The auth engine's
776    /// capability matrix is future work; until it lands, every routed
777    /// LLM-NER call denies at the gate and `extract_tokens_routed`'s
778    /// heuristic fallback fires (see `ask_pipeline::extract_tokens_routed`).
779    /// Documented in code so the wire-up is a one-line change once
780    /// the auth engine learns capabilities.
781    pub fn has_capability(&self, _capability: &str) -> bool {
782        false
783    }
784}
785
786impl ReadFrame for EffectiveScope {
787    fn effective_scope(&self) -> Option<&str> {
788        self.tenant.as_deref()
789    }
790    fn identity(&self) -> Option<(&str, Role)> {
791        self.identity.as_ref().map(|(u, r)| (u.as_str(), *r))
792    }
793    fn snapshot(&self) -> &Snapshot {
794        &self.snapshot
795    }
796    fn as_of_floor(&self) -> Option<Xid> {
797        None
798    }
799    fn cache_key(&self) -> &str {
800        ""
801    }
802    fn should_cache_result(&self) -> bool {
803        false
804    }
805    fn required_privilege(&self) -> Privilege {
806        Privilege::Read
807    }
808    fn lock_intent(&self) -> LockIntent {
809        LockIntent::Shared
810    }
811    fn visible_collections(&self) -> Option<&HashSet<String>> {
812        self.visible_collections.as_ref()
813    }
814}
815
816fn own_transaction_xids(
817    ctx: Option<&crate::storage::transaction::snapshot::TxnContext>,
818) -> HashSet<Xid> {
819    let mut set = HashSet::new();
820    if let Some(ctx) = ctx {
821        set.insert(ctx.xid);
822        for (_, sub) in &ctx.savepoints {
823            set.insert(*sub);
824        }
825        for sub in &ctx.released_sub_xids {
826            set.insert(*sub);
827        }
828    }
829    set
830}
831
832impl RedDBRuntime {
833    /// Build the AI command `EffectiveScope` from the current
834    /// statement thread-locals + auth store.
835    ///
836    /// Returns `None` for embedded callers (no auth store, no
837    /// identity) — `AuthorizedSearch` treats `None` as deny-default.
838    pub(crate) fn ai_scope(&self) -> EffectiveScope {
839        let tenant = super::impl_core::current_tenant();
840        let identity = super::impl_core::current_auth_identity();
841        let snapshot = self.current_snapshot();
842        let visible_collections = match (self.inner.auth_store.read().clone(), identity.as_ref()) {
843            (Some(store), Some((principal, role))) => {
844                let collections = self.inner.db.store().list_collections();
845                Some(store.visible_collections_for_scope(
846                    tenant.as_deref(),
847                    *role,
848                    principal,
849                    &collections,
850                ))
851            }
852            _ => None,
853        };
854        EffectiveScope {
855            tenant,
856            identity,
857            snapshot,
858            visible_collections,
859        }
860    }
861}
862
863/// Test fixtures for callers that need to drive `ReadFrame` without
864/// booting a runtime. Lives behind `cfg(test)` and `pub(crate)` so it
865/// only leaks across module boundaries inside the crate.
866#[cfg(test)]
867pub(crate) mod test_support {
868    use super::{LockIntent, Privilege, ReadFrame};
869    use crate::auth::Role;
870    use crate::storage::transaction::snapshot::{Snapshot, Xid};
871    use std::collections::HashSet;
872
873    /// A `ReadFrame` impl with hand-set fields. Used by
874    /// `authorized_search` tests to assert the deny-default and
875    /// scope-trim behaviour without going through frame construction.
876    pub(crate) struct FakeReadFrame {
877        pub tenant: Option<String>,
878        pub identity: Option<(String, Role)>,
879        pub snapshot: Snapshot,
880        pub visible: Option<HashSet<String>>,
881    }
882
883    impl FakeReadFrame {
884        pub(crate) fn without_scope() -> Self {
885            Self {
886                tenant: None,
887                identity: None,
888                snapshot: Snapshot {
889                    xid: 0,
890                    in_progress: HashSet::new(),
891                },
892                visible: None,
893            }
894        }
895
896        pub(crate) fn with_visible(visible: HashSet<String>) -> Self {
897            Self {
898                tenant: Some("acme".to_string()),
899                identity: Some(("alice".to_string(), Role::Read)),
900                snapshot: Snapshot {
901                    xid: 0,
902                    in_progress: HashSet::new(),
903                },
904                visible: Some(visible),
905            }
906        }
907    }
908
909    impl ReadFrame for FakeReadFrame {
910        fn effective_scope(&self) -> Option<&str> {
911            self.tenant.as_deref()
912        }
913        fn identity(&self) -> Option<(&str, Role)> {
914            self.identity.as_ref().map(|(u, r)| (u.as_str(), *r))
915        }
916        fn snapshot(&self) -> &Snapshot {
917            &self.snapshot
918        }
919        fn as_of_floor(&self) -> Option<Xid> {
920            None
921        }
922        fn cache_key(&self) -> &str {
923            ""
924        }
925        fn should_cache_result(&self) -> bool {
926            false
927        }
928        fn required_privilege(&self) -> Privilege {
929            Privilege::Read
930        }
931        fn lock_intent(&self) -> LockIntent {
932            LockIntent::Shared
933        }
934        fn visible_collections(&self) -> Option<&HashSet<String>> {
935            self.visible.as_ref()
936        }
937    }
938}
939
940impl RedDBRuntime {
941    /// Resolve the snapshot for the current statement, returning
942    /// the snapshot itself and (when AS OF is in effect) the
943    /// resolved xid floor. The floor is the same xid carried inside
944    /// `Snapshot.xid` for AS OF reads — exposing it separately lets
945    /// the `ReadFrame` Interface tell "live read" from "historical
946    /// read" without inferring from `in_progress.is_empty()`.
947    fn statement_snapshot(&self, query: &str) -> RedDBResult<(Snapshot, Option<Xid>)> {
948        match peek_top_level_as_of_with_table(query) {
949            Some((spec, Some(table))) => {
950                if !table.starts_with("red_") && !self.vcs_is_versioned(&table)? {
951                    return Err(RedDBError::InvalidConfig(format!(
952                        "AS OF requires a versioned collection — \
953                         `{table}` has not opted in. \
954                         Call vcs.set_versioned(\"{table}\", true) first."
955                    )));
956                }
957                let xid = self.vcs_resolve_as_of(spec)?;
958                Ok((
959                    Snapshot {
960                        xid,
961                        in_progress: HashSet::new(),
962                    },
963                    Some(xid),
964                ))
965            }
966            Some((spec, None)) => {
967                let xid = self.vcs_resolve_as_of(spec)?;
968                Ok((
969                    Snapshot {
970                        xid,
971                        in_progress: HashSet::new(),
972                    },
973                    Some(xid),
974                ))
975            }
976            None => Ok((self.current_snapshot(), None)),
977        }
978    }
979
980    fn result_cache_safe(&self, conn_id: u64) -> bool {
981        let has_active_xids = self.inner.snapshot_manager.oldest_active_xid().is_some();
982        let in_own_tx = self.inner.tx_contexts.read().contains_key(&conn_id);
983        !has_active_xids && !in_own_tx
984    }
985}
986
987/// Whether a result's `engine` tag is one of the graph-analytics TVF
988/// executors (issue #802). Graph-collection (`louvain(g)`) and inline
989/// (`louvain(nodes => …, edges => …)`) forms both produce deterministic
990/// algorithm output that is cached regardless of row count.
991fn is_graph_tvf_engine(engine: &str) -> bool {
992    matches!(engine, "runtime-graph-tvf" | "runtime-graph-tvf-inline")
993}
994
995fn result_cache_key(query: &str) -> String {
996    let tenant = current_tenant().unwrap_or_default();
997    let auth = current_auth_identity()
998        .map(|(user, role)| format!("{}|{:?}", user, role))
999        .unwrap_or_default();
1000    if tenant.is_empty() && auth.is_empty() {
1001        query.to_string()
1002    } else {
1003        format!("{query}\u{001e}{tenant}\u{001e}{auth}")
1004    }
1005}
1006
1007#[cfg(test)]
1008mod tests {
1009    use super::*;
1010    use crate::api::RedDBOptions;
1011    use crate::runtime::impl_core::{
1012        clear_current_auth_identity, clear_current_tenant, set_current_auth_identity,
1013        set_current_tenant,
1014    };
1015    use crate::runtime::RedDBRuntime;
1016
1017    fn fresh_runtime() -> RedDBRuntime {
1018        RedDBRuntime::with_options(RedDBOptions::in_memory()).expect("in-memory runtime")
1019    }
1020
1021    /// Ensure thread-local state from a prior test can't leak into
1022    /// the next one — tests in the same binary share the thread.
1023    fn reset_thread_locals() {
1024        clear_current_tenant();
1025        clear_current_auth_identity();
1026    }
1027
1028    #[test]
1029    fn autocommit_select_takes_live_snapshot() {
1030        reset_thread_locals();
1031        let rt = fresh_runtime();
1032        let frame =
1033            StatementExecutionFrame::build(&rt, "SELECT 1").expect("frame builds for SELECT 1");
1034
1035        // Live reads: no AS OF floor, snapshot bounded by the
1036        // manager's `peek_next_xid` so committed tuples are visible.
1037        let f: &dyn ReadFrame = &frame;
1038        assert!(f.as_of_floor().is_none(), "live read has no AS OF floor");
1039        assert!(
1040            f.snapshot().xid >= 1,
1041            "autocommit snapshot xid is bounded by peek_next_xid"
1042        );
1043    }
1044
1045    #[test]
1046    fn frame_captures_identity_and_scope() {
1047        reset_thread_locals();
1048        set_current_tenant("acme".to_string());
1049        set_current_auth_identity("alice".to_string(), Role::Write);
1050
1051        let rt = fresh_runtime();
1052        let frame = StatementExecutionFrame::build(&rt, "SELECT 1").expect("frame builds");
1053        let f: &dyn ReadFrame = &frame;
1054
1055        assert_eq!(f.effective_scope(), Some("acme"));
1056        let id = f.identity().expect("identity captured");
1057        assert_eq!(id.0, "alice");
1058        assert!(matches!(id.1, Role::Write));
1059
1060        // Cache key mixes scope + identity so two callers under
1061        // different tenants never share a cache slot.
1062        assert!(
1063            f.cache_key().contains("acme") && f.cache_key().contains("alice"),
1064            "cache key folds in scope + identity, got {:?}",
1065            f.cache_key()
1066        );
1067
1068        reset_thread_locals();
1069    }
1070
1071    #[test]
1072    fn as_of_rejects_non_versioned_user_collection() {
1073        reset_thread_locals();
1074        let rt = fresh_runtime();
1075
1076        // `not_versioned` is a plain user collection — the frame
1077        // builder must reject AS OF until the caller opts in via
1078        // `vcs.set_versioned`.
1079        let err = match StatementExecutionFrame::build(
1080            &rt,
1081            "SELECT * FROM not_versioned AS OF COMMIT 'deadbeef'",
1082        ) {
1083            Err(e) => e,
1084            Ok(_) => panic!("AS OF on non-versioned user collection rejected"),
1085        };
1086
1087        let msg = format!("{err}");
1088        assert!(
1089            msg.contains("AS OF requires a versioned collection"),
1090            "expected AS OF rejection, got: {msg}"
1091        );
1092    }
1093
1094    /// End-to-end proof that the SELECT path consumes a `ReadFrame`.
1095    ///
1096    /// Sets a tenant + identity via the public thread-local API the
1097    /// runtime uses for ambient scope, drives a real `SELECT` through
1098    /// `execute_query`, then inspects the result cache that the SELECT
1099    /// path populates via `frame.cache_key()`. The key only carries
1100    /// the tenant + identity *because* it was built through the frame —
1101    /// reverting the wiring to inline `current_tenant()` /
1102    /// `current_auth_identity()` reads would still pass this test, but
1103    /// dropping the frame entirely (so the SELECT path stopped touching
1104    /// `cache_key`) would break it.
1105    #[test]
1106    fn select_path_routes_through_frame_cache_key() {
1107        reset_thread_locals();
1108        set_current_tenant("acme".to_string());
1109        set_current_auth_identity("alice".to_string(), Role::Read);
1110
1111        let rt = fresh_runtime();
1112        let result = rt
1113            .execute_query("SELECT 1")
1114            .expect("SELECT 1 executes under tenant=acme/identity=alice");
1115        assert_eq!(result.statement_type, "select");
1116
1117        // The textual SELECT path builds a frame and
1118        // writes its result through `frame.cache_key()`. That key folds
1119        // tenant + identity in via `result_cache_key`, so finding "acme"
1120        // and "alice" inside any cached key proves the frame was the
1121        // seam used.
1122        let cache = rt.inner.result_cache.read();
1123        let any_keyed_with_scope = cache
1124            .0
1125            .keys()
1126            .any(|k| k.contains("acme") && k.contains("alice"));
1127        assert!(
1128            any_keyed_with_scope,
1129            "expected at least one result-cache key carrying tenant+identity, \
1130             got keys: {:?}",
1131            cache.0.keys().collect::<Vec<_>>()
1132        );
1133
1134        reset_thread_locals();
1135    }
1136
1137    /// A SELECT that calls a volatile builtin (here:
1138    /// `pg_advisory_unlock`, the volatile token the runtime currently
1139    /// recognises in `query_has_volatile_builtin`) must NOT populate
1140    /// the result cache. Any caller hitting the cache after this would
1141    /// see a stale answer for an inherently-volatile query, so the
1142    /// SELECT path gates writes through `frame.should_cache_result()`.
1143    ///
1144    /// Deletion test: removing `ReadFrame::should_cache_result`, or
1145    /// reverting the SELECT path to skip its safety gate, would let
1146    /// the result cache silently absorb this statement and break the
1147    /// assertion below.
1148    #[test]
1149    fn volatile_select_does_not_populate_result_cache() {
1150        reset_thread_locals();
1151        let rt = fresh_runtime();
1152
1153        // Frame-level invariant: the volatile-builtin signal collapses
1154        // `should_cache_result` to false even for an autocommit /
1155        // out-of-tx connection.
1156        let frame =
1157            StatementExecutionFrame::build(&rt, "SELECT pg_advisory_unlock(1)").expect("frame");
1158        let f: &dyn ReadFrame = &frame;
1159        assert!(
1160            !f.should_cache_result(),
1161            "volatile builtin must disable result-cache safety"
1162        );
1163
1164        // End-to-end: drive the volatile SELECT through `execute_query`
1165        // and confirm no entry was stamped under its cache key. Other
1166        // entries from prior tests sharing the binary may exist, so we
1167        // assert specifically on this query's key.
1168        let _ = rt
1169            .execute_query("SELECT pg_advisory_unlock(1)")
1170            .expect("volatile SELECT executes");
1171        let cache = rt.inner.result_cache.read();
1172        let key = result_cache_key("SELECT pg_advisory_unlock(1)");
1173        assert!(
1174            !cache.0.contains_key(&key),
1175            "volatile SELECT must not populate result cache, found key {key:?} in {:?}",
1176            cache.0.keys().collect::<Vec<_>>()
1177        );
1178
1179        reset_thread_locals();
1180    }
1181
1182    #[test]
1183    fn blob_cache_backend_populates_blob_path_without_legacy_write() {
1184        reset_thread_locals();
1185        let rt = fresh_runtime();
1186        rt.inner
1187            .db
1188            .store()
1189            .set_config_tree("runtime.result_cache.backend", &crate::json!("blob_cache"));
1190
1191        let result = rt.execute_query("SELECT 1").expect("SELECT 1 executes");
1192        assert_eq!(result.statement_type, "select");
1193
1194        let key = result_cache_key("SELECT 1");
1195        assert!(
1196            rt.inner
1197                .result_blob_cache
1198                .get("runtime.result_cache", &key)
1199                .is_some(),
1200            "blob backend should stamp the Blob Cache path"
1201        );
1202        assert!(rt.inner.result_blob_entries.read().0.contains_key(&key));
1203        assert!(
1204            !rt.inner.result_cache.read().0.contains_key(&key),
1205            "blob backend should not write the legacy map"
1206        );
1207    }
1208
1209    #[test]
1210    fn blob_cache_backend_keeps_volatile_select_out_of_blob_path() {
1211        reset_thread_locals();
1212        let rt = fresh_runtime();
1213        rt.inner
1214            .db
1215            .store()
1216            .set_config_tree("runtime.result_cache.backend", &crate::json!("blob_cache"));
1217
1218        let _ = rt
1219            .execute_query("SELECT pg_advisory_unlock(1)")
1220            .expect("volatile SELECT executes");
1221        let key = result_cache_key("SELECT pg_advisory_unlock(1)");
1222        assert!(
1223            rt.inner
1224                .result_blob_cache
1225                .get("runtime.result_cache", &key)
1226                .is_none(),
1227            "volatile SELECT must not populate blob result cache"
1228        );
1229        assert!(!rt.inner.result_blob_entries.read().0.contains_key(&key));
1230    }
1231
1232    #[test]
1233    fn shadow_backend_dual_writes_and_reports_no_divergence_on_equal_results() {
1234        reset_thread_locals();
1235        let rt = fresh_runtime();
1236        rt.inner
1237            .db
1238            .store()
1239            .set_config_tree("runtime.result_cache.backend", &crate::json!("shadow"));
1240
1241        let first = rt.execute_query("SELECT 1").expect("first SELECT");
1242        let second = rt.execute_query("SELECT 1").expect("cached SELECT");
1243        assert_eq!(first.result.len(), second.result.len());
1244
1245        let key = result_cache_key("SELECT 1");
1246        assert!(rt.inner.result_cache.read().0.contains_key(&key));
1247        assert!(rt.inner.result_blob_entries.read().0.contains_key(&key));
1248        assert_eq!(rt.result_cache_shadow_divergences(), 0);
1249        assert_eq!(
1250            crate::runtime::METRIC_CACHE_SHADOW_DIVERGENCE_TOTAL,
1251            "cache_shadow_divergence_total"
1252        );
1253    }
1254
1255    #[test]
1256    fn as_of_on_red_collection_records_floor() {
1257        reset_thread_locals();
1258        let rt = fresh_runtime();
1259
1260        // `red_*` collections always allow AS OF. The frame should
1261        // resolve to a concrete xid and surface it via the Interface.
1262        let frame =
1263            StatementExecutionFrame::build(&rt, "SELECT * FROM red_commits AS OF SNAPSHOT 1")
1264                .expect("AS OF SNAPSHOT 1 on red_commits resolves");
1265
1266        let f: &dyn ReadFrame = &frame;
1267        assert_eq!(
1268            f.as_of_floor(),
1269            Some(1),
1270            "AS OF SNAPSHOT 1 records xid=1 as the floor"
1271        );
1272        assert_eq!(f.snapshot().xid, 1);
1273        assert!(
1274            f.snapshot().in_progress.is_empty(),
1275            "historical reads have no in-progress set"
1276        );
1277    }
1278
1279    /// The frame classifies common SQL prefixes into the coarse
1280    /// `Privilege` / `LockIntent` buckets at build time. This test
1281    /// pins the mapping so a regression that silently re-routes
1282    /// (e.g. INSERT classified as Read) surfaces here, not at a
1283    /// downstream privilege gate.
1284    #[test]
1285    fn frame_classifies_privilege_and_lock_intent_from_prefix() {
1286        reset_thread_locals();
1287        let rt = fresh_runtime();
1288
1289        let cases = [
1290            ("SELECT 1", Privilege::Read, LockIntent::Shared),
1291            ("LIST KV settings", Privilege::Read, LockIntent::Shared),
1292            (
1293                "KV GET settings.feature",
1294                Privilege::Read,
1295                LockIntent::Shared,
1296            ),
1297            ("VAULT LIST secrets", Privilege::Read, LockIntent::Shared),
1298            (
1299                "INSERT INTO t (id) VALUES (1)",
1300                Privilege::Write,
1301                LockIntent::Exclusive,
1302            ),
1303            (
1304                "KV PUT settings.feature = 'on'",
1305                Privilege::Write,
1306                LockIntent::Exclusive,
1307            ),
1308            (
1309                "VAULT PUT secrets.api = 'x'",
1310                Privilege::Write,
1311                LockIntent::Exclusive,
1312            ),
1313            (
1314                "UPDATE t SET x = 1 WHERE id = 1",
1315                Privilege::Write,
1316                LockIntent::Exclusive,
1317            ),
1318            (
1319                "DELETE FROM t WHERE id = 1",
1320                Privilege::Write,
1321                LockIntent::Exclusive,
1322            ),
1323            (
1324                "CREATE TABLE foo (id INT)",
1325                Privilege::Write,
1326                LockIntent::Exclusive,
1327            ),
1328            ("BEGIN", Privilege::None, LockIntent::None),
1329            ("COMMIT", Privilege::None, LockIntent::None),
1330            ("SET timezone = 'UTC'", Privilege::None, LockIntent::None),
1331        ];
1332
1333        for (q, want_priv, want_lock) in cases {
1334            let frame = StatementExecutionFrame::build(&rt, q)
1335                .unwrap_or_else(|e| panic!("frame builds for {q:?}: {e}"));
1336            let f: &dyn ReadFrame = &frame;
1337            assert_eq!(f.required_privilege(), want_priv, "privilege for {q:?}");
1338            assert_eq!(f.lock_intent(), want_lock, "lock intent for {q:?}");
1339        }
1340    }
1341
1342    /// Deletion-test for `ReadFrame::required_privilege`: a SELECT
1343    /// driven through `execute_query` under an identity whose role
1344    /// doesn't satisfy the frame's coarse `Read` privilege gets
1345    /// denied with the frame's signal.
1346    ///
1347    /// We test the gate by classifying an INSERT (which the frame
1348    /// reports as `Privilege::Write`) under `Role::Read` — the only
1349    /// pair the legacy fallback would also reject, but here the
1350    /// rejection comes through `frame.check_query_privilege` BEFORE
1351    /// the parsed-expression walker runs. Removing
1352    /// `required_privilege` (or the `is_satisfied_by` consult inside
1353    /// `check_query_privilege`) would force the deny path back to the
1354    /// inline `RedDBRuntime::check_query_privilege` walker — but the
1355    /// auth_store gate up there is bypassed when no auth_store is
1356    /// wired (embedded test mode), so this test would FLIP from
1357    /// denied to permitted and break the assertion below.
1358    #[test]
1359    fn insert_under_read_role_denied_via_frame_privilege() {
1360        reset_thread_locals();
1361        set_current_auth_identity("alice".to_string(), Role::Read);
1362
1363        let rt = fresh_runtime();
1364        // Bypass parser by reaching into the frame directly: the
1365        // frame derives privilege from the SQL prefix without
1366        // needing an auth_store wired up. Driving end-to-end via
1367        // `execute_query` would also reject (no table `t`), but for
1368        // a different reason — we want to pin the privilege seam.
1369        let frame = StatementExecutionFrame::build(&rt, "INSERT INTO t (id) VALUES (1)")
1370            .expect("frame builds for INSERT");
1371        let f: &dyn ReadFrame = &frame;
1372        assert_eq!(
1373            f.required_privilege(),
1374            Privilege::Write,
1375            "INSERT classified as Write"
1376        );
1377        let id = f.identity().expect("identity captured");
1378        assert!(
1379            !f.required_privilege().is_satisfied_by(id.1),
1380            "Role::Read does not satisfy Privilege::Write — frame must deny"
1381        );
1382
1383        // End-to-end: the frame's `check_query_privilege` sees the
1384        // (Read role, Write privilege) mismatch and denies before
1385        // dispatch. We drive a synthetic `QueryExpr::Table` because
1386        // the SELECT/INSERT parser would happen to also fail, and we
1387        // want the failure to come from the privilege seam.
1388        use crate::storage::query::ast::{QueryExpr, TableQuery};
1389        let expr = QueryExpr::Table(TableQuery::new("t"));
1390        let err = frame
1391            .check_query_privilege(&rt, &expr)
1392            .expect_err("denied via frame's coarse privilege gate");
1393        let msg = format!("{err}");
1394        assert!(
1395            msg.contains("permission denied") && msg.contains("Write"),
1396            "expected frame-level Write deny, got: {msg}"
1397        );
1398
1399        reset_thread_locals();
1400    }
1401
1402    /// End-to-end proof that the frame-owned row-buffer arena (#885) is
1403    /// wired into the SELECT path and produces observable results
1404    /// byte-identical to the per-request-allocation baseline.
1405    ///
1406    /// A table with more rows than the streaming high-water mark
1407    /// (`DEFAULT_HIGH_WATER_MARK`) forces the `execute_runtime_table_query_in`
1408    /// path to assemble many chunks, each leasing/recycling the frame
1409    /// arena's single chunk buffer. Driving it through `execute_query`
1410    /// (which builds a `StatementExecutionFrame` and lends its arena)
1411    /// must return every inserted row, in order — exactly what the
1412    /// allocate-per-chunk path returned. A bug in the arena wiring
1413    /// (dropped rows, bled rows, mis-ordering) would surface here.
1414    #[test]
1415    fn large_select_through_frame_arena_returns_all_rows_in_order() {
1416        reset_thread_locals();
1417        let rt = fresh_runtime();
1418        rt.execute_query("CREATE TABLE big (id INT)")
1419            .expect("create table");
1420
1421        // > DEFAULT_HIGH_WATER_MARK (1024) rows so the streaming channel
1422        // spans multiple chunks and the arena buffer is reused.
1423        const N: usize = 2_500;
1424        for start in (0..N).step_by(250) {
1425            let end = (start + 250).min(N);
1426            let values = (start..end)
1427                .map(|i| format!("({i})"))
1428                .collect::<Vec<_>>()
1429                .join(", ");
1430            rt.execute_query(&format!("INSERT INTO big (id) VALUES {values}"))
1431                .unwrap_or_else(|err| panic!("insert rows {start}..{end}: {err:?}"));
1432        }
1433
1434        let result = rt
1435            .execute_query("SELECT id FROM big ORDER BY id")
1436            .expect("large SELECT executes through the frame arena path");
1437        assert_eq!(result.statement_type, "select");
1438        assert_eq!(
1439            result.result.records.len(),
1440            N,
1441            "every inserted row streams back through the arena-backed channel"
1442        );
1443        for (i, record) in result.result.records.iter().enumerate() {
1444            assert_eq!(
1445                record.get("id"),
1446                Some(&crate::storage::schema::Value::Integer(i as i64)),
1447                "row {i} is byte-identical to the per-request-allocation baseline"
1448            );
1449        }
1450
1451        reset_thread_locals();
1452    }
1453
1454    /// Transport adapters may decode their wire-specific parameter value
1455    /// shapes, but SQL parsing/binding must stay behind the runtime's
1456    /// statement entrypoint. This pins the deeper seam introduced for
1457    /// parameterized query execution: HTTP, JSON-RPC, RedWire, PG wire,
1458    /// and gRPC all call `RedDBRuntime::execute_query_with_params`, which
1459    /// installs a real `StatementExecutionFrame` before dispatch.
1460    #[test]
1461    fn parameterized_transport_adapters_delegate_binding_to_runtime() {
1462        let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
1463        let adapters = [
1464            "src/server/handlers_query.rs",
1465            "src/rpc_stdio.rs",
1466            "src/wire/redwire/session.rs",
1467            "src/wire/postgres/server.rs",
1468            "src/grpc.rs",
1469        ];
1470
1471        for relative in adapters {
1472            let path = manifest_dir.join(relative);
1473            let text = std::fs::read_to_string(&path)
1474                .unwrap_or_else(|err| panic!("read {}: {err}", path.display()));
1475            assert!(
1476                text.contains("execute_query_with_params"),
1477                "{relative} should delegate parameterized query execution to the runtime"
1478            );
1479            assert!(
1480                !text.contains("user_params::bind"),
1481                "{relative} must not bind SQL params in the transport adapter"
1482            );
1483        }
1484    }
1485
1486    /// Deletion-test for `ReadFrame::lock_intent`: a transaction
1487    /// control statement carries `LockIntent::None` and the
1488    /// `acquire_intent_locks` path returns `None` without consulting
1489    /// `intent_lock_modes_for`. Removing the method (or its consult
1490    /// site in `acquire_intent_locks`) would force the lock-mode
1491    /// helper to walk a fabricated parsed expression to reach the
1492    /// same conclusion — but the assertion that no guard is allocated
1493    /// for a `BEGIN` frame would still hold, so we additionally pin
1494    /// the classifier mapping above to make the deletion observable.
1495    #[test]
1496    fn control_statement_skips_intent_locks_via_frame() {
1497        reset_thread_locals();
1498        let rt = fresh_runtime();
1499
1500        let frame = StatementExecutionFrame::build(&rt, "BEGIN").expect("frame builds for BEGIN");
1501        let f: &dyn ReadFrame = &frame;
1502        assert_eq!(f.lock_intent(), LockIntent::None);
1503
1504        // Drive `acquire_intent_locks` against a fabricated SELECT
1505        // expression that WOULD normally yield `(IS, IS)`; the frame's
1506        // `lock_intent() == None` short-circuit must still suppress
1507        // the guard.
1508        use crate::storage::query::ast::{QueryExpr, TableQuery};
1509        let expr = QueryExpr::Table(TableQuery::new("t"));
1510        let guard = frame.acquire_intent_locks(&rt, &expr);
1511        assert!(
1512            guard.is_none(),
1513            "BEGIN frame's lock_intent=None must short-circuit lock acquisition"
1514        );
1515    }
1516}