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::replication::cdc::ChangeRecord;
5use crate::storage::query::ast::TableSource;
6
7/// Read a numeric score column out of a result record as `f64`, matching
8/// the column name case-insensitively. Used by the leaderboard-rank head
9/// walk (#918) to compare scores; non-numeric / missing columns yield
10/// `None` so a row with no comparable score never shifts a rank.
11fn record_column_f64(
12    rec: &crate::storage::query::unified::UnifiedRecord,
13    column: &str,
14) -> Option<f64> {
15    let value = rec
16        .get(column)
17        .or_else(|| rec.get(&column.to_lowercase()))?;
18    match value {
19        Value::Integer(n) => Some(*n as f64),
20        Value::UnsignedInteger(n) => Some(*n as f64),
21        Value::Float(n) => Some(*n),
22        Value::Timestamp(n) | Value::Duration(n) => Some(*n as f64),
23        _ => None,
24    }
25}
26
27fn record_rid_u64(rec: &crate::storage::query::unified::UnifiedRecord) -> Option<u64> {
28    match rec.get("rid") {
29        Some(Value::UnsignedInteger(n)) => Some(*n),
30        Some(Value::Integer(n)) if *n >= 0 => Some(*n as u64),
31        _ => None,
32    }
33}
34
35fn seed_storage_deploy_config(
36    store: &crate::storage::UnifiedStore,
37    selection: crate::storage::StorageProfileSelection,
38) {
39    store.set_config_tree(
40        "storage.deploy",
41        &crate::json!({
42            "profile": selection.deploy_profile.as_str(),
43            "packaging": selection.packaging.as_str(),
44            "preset": selection.preset_name(),
45            "replica_count": selection.replica_count,
46            "managed_backup": selection.managed_backup,
47            "wal_retention": selection.wal_retention,
48        }),
49    );
50}
51
52struct RankedHeadEntry {
53    rank: u64,
54    record: crate::storage::query::unified::UnifiedRecord,
55}
56
57fn show_secrets_allows_key(key: &str) -> bool {
58    !key.starts_with("red.secret.") && !key.starts_with("red.config.")
59}
60
61fn secret_sql_value_to_string(value: &Value) -> RedDBResult<String> {
62    match value {
63        Value::Text(s) => Ok(s.to_string()),
64        Value::Integer(n) => Ok(n.to_string()),
65        Value::UnsignedInteger(n) => Ok(n.to_string()),
66        Value::Float(n) => Ok(n.to_string()),
67        Value::Boolean(b) => Ok(b.to_string()),
68        Value::Null => Err(RedDBError::Query(
69            "SET SECRET key = NULL deletes the secret; use DELETE SECRET for explicit deletes"
70                .to_string(),
71        )),
72        Value::Password(_) | Value::Secret(_) => Err(RedDBError::Query(
73            "SET SECRET accepts plain scalar literals; PASSWORD() and SECRET() are for typed columns"
74                .to_string(),
75        )),
76        _ => Err(RedDBError::Query(format!(
77            "SET SECRET does not support value type {:?} yet",
78            value.data_type()
79        ))),
80    }
81}
82
83#[cfg(test)]
84mod tests {
85    use super::*;
86
87    #[test]
88    fn show_secrets_allows_only_user_managed_keys() {
89        assert!(!show_secrets_allows_key("red.secret.aes_key"));
90        assert!(!show_secrets_allows_key(
91            "red.secret.ai.anthropic.default.api_key"
92        ));
93        assert!(!show_secrets_allows_key("red.config.ai.default.provider"));
94        assert!(show_secrets_allows_key("acme.key"));
95    }
96}
97
98fn insert_config_json_path(
99    root: &mut crate::serde_json::Value,
100    path: &str,
101    value: crate::serde_json::Value,
102) {
103    let segments: Vec<&str> = path
104        .split('.')
105        .filter(|segment| !segment.is_empty())
106        .collect();
107    insert_config_json_segments(root, &segments, value);
108}
109
110fn insert_config_json_segments(
111    root: &mut crate::serde_json::Value,
112    segments: &[&str],
113    value: crate::serde_json::Value,
114) {
115    if segments.is_empty() {
116        *root = value;
117        return;
118    }
119
120    if !matches!(root, crate::serde_json::Value::Object(_)) {
121        *root = crate::serde_json::Value::Object(crate::serde_json::Map::new());
122    }
123
124    let crate::serde_json::Value::Object(map) = root else {
125        return;
126    };
127    if segments.len() == 1 {
128        map.insert(segments[0].to_string(), value);
129        return;
130    }
131    let entry = map
132        .entry(segments[0].to_string())
133        .or_insert_with(|| crate::serde_json::Value::Object(crate::serde_json::Map::new()));
134    insert_config_json_segments(entry, &segments[1..], value);
135}
136
137fn show_config_json_result(
138    query: &str,
139    mode: crate::storage::query::modes::QueryMode,
140    prefix: &Option<String>,
141    value: crate::serde_json::Value,
142) -> RuntimeQueryResult {
143    let mut result = UnifiedResult::with_columns(vec!["key".into(), "value".into()]);
144    let mut record = UnifiedRecord::new();
145    record.set(
146        "key",
147        prefix
148            .as_ref()
149            .map(|key| Value::text(key.clone()))
150            .unwrap_or(Value::Null),
151    );
152    record.set("value", Value::Json(value.to_string_compact().into_bytes()));
153    result.push(record);
154    RuntimeQueryResult {
155        query: query.to_string(),
156        mode,
157        statement: "show_config_json",
158        engine: "runtime-config",
159        result,
160        affected_rows: 0,
161        statement_type: "select",
162        bookmark: None,
163    }
164}
165
166#[derive(Clone)]
167struct QueryControlEventSpec {
168    kind: crate::runtime::control_events::EventKind,
169    action: &'static str,
170    resource: Option<String>,
171    fields: Vec<(String, crate::runtime::control_events::Sensitivity)>,
172}
173
174#[derive(Clone)]
175struct QueryAuditPlan {
176    statement_kind: &'static str,
177    collections: Vec<String>,
178}
179
180fn query_audit_plan(expr: &QueryExpr) -> Option<QueryAuditPlan> {
181    let mut collections = Vec::new();
182    let statement_kind = match expr {
183        QueryExpr::Table(table) => {
184            push_query_audit_collection(&mut collections, &table.table);
185            "select"
186        }
187        QueryExpr::Join(join) => {
188            collect_query_audit_collections(&join.left, &mut collections);
189            collect_query_audit_collections(&join.right, &mut collections);
190            "select"
191        }
192        QueryExpr::Insert(insert) => {
193            push_query_audit_collection(&mut collections, &insert.table);
194            "insert"
195        }
196        QueryExpr::Update(update) => {
197            push_query_audit_collection(&mut collections, &update.table);
198            "update"
199        }
200        QueryExpr::Delete(delete) => {
201            push_query_audit_collection(&mut collections, &delete.table);
202            "delete"
203        }
204        _ => return None,
205    };
206    if collections.is_empty() {
207        None
208    } else {
209        Some(QueryAuditPlan {
210            statement_kind,
211            collections,
212        })
213    }
214}
215
216fn collect_query_audit_collections(expr: &QueryExpr, collections: &mut Vec<String>) {
217    match expr {
218        QueryExpr::Table(table) => push_query_audit_collection(collections, &table.table),
219        QueryExpr::Join(join) => {
220            collect_query_audit_collections(&join.left, collections);
221            collect_query_audit_collections(&join.right, collections);
222        }
223        _ => {}
224    }
225}
226
227fn push_query_audit_collection(collections: &mut Vec<String>, name: &str) {
228    if name == "red" || name.starts_with("red.") || name.starts_with("__red_schema_") {
229        return;
230    }
231    if !collections.iter().any(|existing| existing == name) {
232        collections.push(name.to_string());
233    }
234}
235
236const RUNTIME_INDEX_REGISTRY_COLLECTION: &str = "red_index_registry";
237
238impl RedDBRuntime {
239    fn execute_create_metric(
240        &self,
241        raw_query: &str,
242        query: &crate::storage::query::ast::CreateMetricQuery,
243    ) -> RedDBResult<RuntimeQueryResult> {
244        self.check_write(crate::runtime::write_gate::WriteKind::Ddl)?;
245        let store = self.inner.db.store();
246        super::metric_descriptor_catalog::create(
247            store.as_ref(),
248            &query.path,
249            &query.kind,
250            &query.role,
251            super::metric_descriptor_catalog::DerivedSpec {
252                source: query.source.clone(),
253                query: query.query.clone(),
254                window_ms: query.window_ms,
255                time_field: query.time_field.clone(),
256            },
257        )?;
258        self.invalidate_result_cache();
259        Ok(RuntimeQueryResult::ok_message(
260            raw_query.to_string(),
261            &format!("metric descriptor '{}' created", query.path),
262            "create",
263        ))
264    }
265
266    /// `CREATE RANKING <name> ON <table> (<column> [ASC|DESC]) [TOP <k>]`
267    /// — declare a Ranking capability over an ordinary table's score
268    /// column (issue #918 / ADR 0035). Persists a WAL-backed catalog
269    /// record; no new Collection model is introduced. Authorized through
270    /// the same DDL write gate as `CREATE METRIC`/`CREATE INDEX`.
271    fn execute_create_ranking(
272        &self,
273        raw_query: &str,
274        req: super::ranking_descriptor_catalog::CreateRankingRequest,
275    ) -> RedDBResult<RuntimeQueryResult> {
276        self.check_write(crate::runtime::write_gate::WriteKind::Ddl)?;
277        let store = self.inner.db.store();
278        let descriptor = super::ranking_descriptor_catalog::create(store.as_ref(), &req)?;
279        self.invalidate_result_cache();
280        Ok(RuntimeQueryResult::ok_message(
281            raw_query.to_string(),
282            &format!(
283                "ranking '{}' created on {}({})",
284                descriptor.name, descriptor.table, descriptor.column
285            ),
286            "create",
287        ))
288    }
289
290    /// `SHOW RANKINGS` — project the declared Ranking capabilities back as
291    /// rows, so a declared capability is observable (the Analytics
292    /// "prefer SELECT over admin verbs" rule).
293    fn execute_show_rankings(&self, raw_query: &str) -> RedDBResult<RuntimeQueryResult> {
294        let store = self.inner.db.store();
295        let entries = super::ranking_descriptor_catalog::list(store.as_ref());
296        let columns = vec![
297            "name".to_string(),
298            "table".to_string(),
299            "column".to_string(),
300            "direction".to_string(),
301            "top_k".to_string(),
302        ];
303        let rows = entries
304            .into_iter()
305            .map(|e| {
306                vec![
307                    ("name".to_string(), Value::text(e.name)),
308                    ("table".to_string(), Value::text(e.table)),
309                    ("column".to_string(), Value::text(e.column)),
310                    (
311                        "direction".to_string(),
312                        Value::text(if e.descending { "DESC" } else { "ASC" }.to_string()),
313                    ),
314                    ("top_k".to_string(), Value::UnsignedInteger(e.top_k)),
315                ]
316            })
317            .collect();
318        let mut result =
319            RuntimeQueryResult::ok_records(raw_query.to_string(), columns, rows, "select");
320        result.statement = "rank_of";
321        result.engine = "runtime-rank";
322        Ok(result)
323    }
324
325    /// `RANK OF <id> IN <name>` — exact, MVCC-correct rank of a specific
326    /// row within the capability's bounded top-K head (issue #918).
327    ///
328    /// Returns a single `rank` row when the row is visible *and* falls
329    /// inside the exact head; an empty result otherwise (not visible, or
330    /// in the approximate tail — a separate slice). The computation runs
331    /// entirely over the regular read pipeline so it inherits MVCC
332    /// visibility, RLS/policy, and tenant scope from ordinary reads.
333    fn execute_rank_of(
334        &self,
335        raw_query: &str,
336        req: &crate::storage::query::ast::RankOfQuery,
337    ) -> RedDBResult<RuntimeQueryResult> {
338        let store = self.inner.db.store();
339        let descriptor = super::ranking_descriptor_catalog::get(store.as_ref(), &req.ranking)
340            .ok_or_else(|| {
341                RedDBError::Query(format!("ranking '{}' does not exist", req.ranking))
342            })?;
343        let rank = self.compute_exact_head_rank(&descriptor, req.entity_id)?;
344        let columns = vec!["rank".to_string()];
345        let rows = match rank {
346            Some(rank) => vec![vec![("rank".to_string(), Value::UnsignedInteger(rank))]],
347            None => Vec::new(),
348        };
349        let mut result =
350            RuntimeQueryResult::ok_records(raw_query.to_string(), columns, rows, "select");
351        result.statement = "rank_range";
352        result.engine = "runtime-rank";
353        Ok(result)
354    }
355
356    /// `RANK RANGE <lo> TO <hi> IN <name>` — exact, MVCC-correct entries
357    /// occupying a contiguous rank range within the bounded top-K head.
358    ///
359    /// The output is in leaderboard order and includes `rank` plus the
360    /// row columns returned by the canonical exact-head SQL read.
361    fn execute_rank_range(
362        &self,
363        raw_query: &str,
364        req: &crate::storage::query::ast::RankRangeQuery,
365    ) -> RedDBResult<RuntimeQueryResult> {
366        let store = self.inner.db.store();
367        let descriptor = super::ranking_descriptor_catalog::get(store.as_ref(), &req.ranking)
368            .ok_or_else(|| {
369                RedDBError::Query(format!("ranking '{}' does not exist", req.ranking))
370            })?;
371        let (head_columns, entries) = self.compute_ranked_head_entries(&descriptor)?;
372
373        let mut columns = Vec::with_capacity(head_columns.len() + 1);
374        columns.push("rank".to_string());
375        for column in &head_columns {
376            if column != "rank" {
377                columns.push(column.clone());
378            }
379        }
380
381        let rows = entries
382            .into_iter()
383            .filter(|entry| entry.rank >= req.lo && entry.rank <= req.hi)
384            .map(|entry| {
385                let mut row = Vec::with_capacity(columns.len());
386                row.push(("rank".to_string(), Value::UnsignedInteger(entry.rank)));
387                for column in &head_columns {
388                    if column == "rank" {
389                        continue;
390                    }
391                    if let Some(value) = entry.record.get(column) {
392                        row.push((column.clone(), value.clone()));
393                    }
394                }
395                row
396            })
397            .collect();
398        let mut result =
399            RuntimeQueryResult::ok_records(raw_query.to_string(), columns, rows, "select");
400        result.statement = "approx_rank_of";
401        result.engine = "runtime-rank";
402        Ok(result)
403    }
404
405    /// Compute the exact rank of `target_id` within the descriptor's
406    /// bounded top-K head, or `None` if the row is invisible to the
407    /// querying snapshot or beyond the exact head.
408    ///
409    /// Faithful to ADR 0035: it walks the sorted index head
410    /// (`ORDER BY <col> {DESC|ASC} LIMIT k`, served by
411    /// `try_sorted_index_lookup` + the per-row MVCC visibility re-check)
412    /// and counts only rows visible to the current snapshot. Running the
413    /// head scan through `execute_query_inner` keeps it on the same
414    /// snapshot/tenant/policy frame as ordinary reads, so the rank agrees
415    /// with `ORDER BY <col> {DESC|ASC} LIMIT` under that snapshot by
416    /// construction. RANK semantics: tied scores share a rank, so the
417    /// rank is `1 + (number of strictly-better visible rows)`.
418    fn compute_exact_head_rank(
419        &self,
420        descriptor: &super::ranking_descriptor_catalog::RankingDescriptor,
421        target_id: u64,
422    ) -> RedDBResult<Option<u64>> {
423        let (_columns, entries) = self.compute_ranked_head_entries(descriptor)?;
424        Ok(entries
425            .into_iter()
426            .find(|entry| record_rid_u64(&entry.record) == Some(target_id))
427            .map(|entry| entry.rank))
428    }
429
430    /// Return the exact head rows in deterministic rank order.
431    fn compute_ranked_head_entries(
432        &self,
433        descriptor: &super::ranking_descriptor_catalog::RankingDescriptor,
434    ) -> RedDBResult<(Vec<String>, Vec<RankedHeadEntry>)> {
435        let table = &descriptor.table;
436        let column = &descriptor.column;
437
438        // The exact head: top-K rows in rank order. Each row here already
439        // passed MVCC visibility *and* RLS/tenant filtering during the
440        // scan, so identifying the target *within* this result (rather
441        // than via a separate `rid` lookup, which takes the
442        // direct entity-fetch path that bypasses the RLS gate) is what
443        // makes the rank honor policy/tenant scope (criterion 5).
444        let dir = if descriptor.descending { "DESC" } else { "ASC" };
445        let head_sql = format!(
446            "SELECT * FROM {table} ORDER BY {column} {dir}, rid ASC LIMIT {}",
447            descriptor.top_k
448        );
449        let head_result = self.execute_query_inner(&head_sql)?;
450
451        let mut entries = Vec::with_capacity(head_result.result.records.len());
452        let mut row_position = 0u64;
453        let mut current_rank = 0u64;
454        let mut previous_score: Option<f64> = None;
455        for rec in &head_result.result.records {
456            let Some(score) = record_column_f64(rec, column) else {
457                continue;
458            };
459            row_position += 1;
460            current_rank = if previous_score == Some(score) {
461                current_rank
462            } else {
463                row_position
464            };
465            previous_score = Some(score);
466            entries.push(RankedHeadEntry {
467                rank: current_rank,
468                record: rec.clone(),
469            });
470        }
471        Ok((head_result.result.columns, entries))
472    }
473
474    /// `APPROX RANK OF <id> IN <name>` — the *approximate tail* read
475    /// (issue #923 / ADR 0035). Serves an explicitly-approximate
476    /// percentile / rank for an entry below the exact top-K head from a
477    /// per-`(table, column)` score sketch.
478    ///
479    /// The result is always labeled approximate (`approximate = true`,
480    /// distinct from the exact `RANK OF` surface which returns only a bare
481    /// `rank`) so a caller never reads a tail estimate as an exact head
482    /// position. An invisible / non-existent row yields no row, exactly
483    /// like the exact surface.
484    fn execute_approx_rank_of(
485        &self,
486        raw_query: &str,
487        req: &crate::storage::query::ast::RankOfQuery,
488    ) -> RedDBResult<RuntimeQueryResult> {
489        let store = self.inner.db.store();
490        let descriptor = super::ranking_descriptor_catalog::get(store.as_ref(), &req.ranking)
491            .ok_or_else(|| {
492                RedDBError::Query(format!("ranking '{}' does not exist", req.ranking))
493            })?;
494
495        let approx = self.compute_approx_rank(&descriptor, req.entity_id)?;
496        let columns = vec![
497            "rank".to_string(),
498            "percentile".to_string(),
499            "approximate".to_string(),
500        ];
501        let rows = match approx {
502            Some(approx) => vec![vec![
503                ("rank".to_string(), Value::UnsignedInteger(approx.rank)),
504                ("percentile".to_string(), Value::Float(approx.percentile)),
505                ("approximate".to_string(), Value::Boolean(true)),
506            ]],
507            None => Vec::new(),
508        };
509        let mut result =
510            RuntimeQueryResult::ok_records(raw_query.to_string(), columns, rows, "select");
511        result.statement = "approx_rank_of";
512        // Tag as `runtime-rank` so the 30s result cache skips this read
513        // (see `should_write_result_cache`). The approximate rank is rebuilt
514        // from a live full scan on every call (criterion 4: it must track
515        // score changes); a cached entry, scoped only to the ranking name and
516        // never the underlying table, would otherwise survive inserts into
517        // that table and serve a stale rank.
518        result.engine = "runtime-rank";
519        Ok(result)
520    }
521
522    /// Refresh the per-`(table, column)` score sketch from the rows visible
523    /// to the current snapshot and return the target's approximate rank, or
524    /// `None` if the target row is invisible to this snapshot / tenant.
525    ///
526    /// The sketch is rebuilt from the live column on each read and persisted
527    /// back to `red_config` keyed by `(table, column)` — so it is maintained
528    /// per `(collection, score column)` and stays current as scores change
529    /// (criterion 4). The scan runs through `execute_query_inner`, inheriting
530    /// the same MVCC snapshot, RLS/tenant scope, and policy as ordinary
531    /// reads. The *approximation* is the histogram bucketing in
532    /// [`super::score_sketch::ScoreSketch`], not the data freshness, so the
533    /// estimate carries the documented error band even though it is built
534    /// from a full scan in this v0 (incremental maintenance is an ADR-0035
535    /// implementation detail, left open and reversible).
536    fn compute_approx_rank(
537        &self,
538        descriptor: &super::ranking_descriptor_catalog::RankingDescriptor,
539        target_id: u64,
540    ) -> RedDBResult<Option<super::score_sketch::ApproxRank>> {
541        let table = &descriptor.table;
542        let column = &descriptor.column;
543
544        // Scan the visible rows once: it both feeds the sketch and locates
545        // the target's score under the same snapshot/tenant/policy frame.
546        let scan_sql = format!("SELECT * FROM {table}");
547        let scan = self.execute_query_inner(&scan_sql)?;
548        let records = &scan.result.records;
549
550        let mut scores: Vec<f64> = Vec::with_capacity(records.len());
551        let mut target_score: Option<f64> = None;
552        for rec in records {
553            let Some(score) = record_column_f64(rec, column) else {
554                continue;
555            };
556            scores.push(score);
557            let rid = match rec.get("rid") {
558                Some(Value::UnsignedInteger(n)) => Some(*n),
559                Some(Value::Integer(n)) if *n >= 0 => Some(*n as u64),
560                _ => None,
561            };
562            if rid == Some(target_id) {
563                target_score = Some(score);
564            }
565        }
566
567        let sketch = super::score_sketch::ScoreSketch::from_scores(&scores);
568        // Persist the refreshed sketch per (table, column).
569        super::ranking_descriptor_catalog::save_sketch(
570            self.inner.db.store().as_ref(),
571            table,
572            column,
573            &sketch,
574        );
575
576        let Some(target_score) = target_score else {
577            // Not visible to this snapshot/tenant ⇒ no rank (matches exact).
578            return Ok(None);
579        };
580        Ok(sketch.approx_rank(target_score, descriptor.descending))
581    }
582
583    fn execute_alter_metric(
584        &self,
585        raw_query: &str,
586        query: &crate::storage::query::ast::AlterMetricQuery,
587    ) -> RedDBResult<RuntimeQueryResult> {
588        self.check_write(crate::runtime::write_gate::WriteKind::Ddl)?;
589        let store = self.inner.db.store();
590        super::metric_descriptor_catalog::update(
591            store.as_ref(),
592            &query.path,
593            query.set_role.as_deref(),
594            query.attempted_kind.as_deref(),
595            query.attempted_path.as_deref(),
596        )?;
597        self.invalidate_result_cache();
598        Ok(RuntimeQueryResult::ok_message(
599            raw_query.to_string(),
600            &format!("metric descriptor '{}' updated", query.path),
601            "alter",
602        ))
603    }
604
605    fn execute_create_slo(
606        &self,
607        raw_query: &str,
608        query: &crate::storage::query::ast::CreateSloQuery,
609    ) -> RedDBResult<RuntimeQueryResult> {
610        self.check_write(crate::runtime::write_gate::WriteKind::Ddl)?;
611        let store = self.inner.db.store();
612        super::slo_descriptor_catalog::create(
613            store.as_ref(),
614            &query.path,
615            &query.metric_path,
616            query.target,
617            query.window_ms,
618        )?;
619        self.invalidate_result_cache();
620        Ok(RuntimeQueryResult::ok_message(
621            raw_query.to_string(),
622            &format!("SLO descriptor '{}' created", query.path),
623            "create",
624        ))
625    }
626
627    fn execute_create_analytics_source(
628        &self,
629        raw_query: &str,
630        query: super::analytics_source_catalog::CreateAnalyticsSourceProfile,
631    ) -> RedDBResult<RuntimeQueryResult> {
632        self.check_write(crate::runtime::write_gate::WriteKind::Ddl)?;
633        let store = self.inner.db.store();
634        let profile = super::analytics_source_catalog::create(
635            store.as_ref(),
636            &self.inner.db.collection_contracts(),
637            query,
638        )?;
639        self.invalidate_result_cache();
640        Ok(RuntimeQueryResult::ok_message(
641            raw_query.to_string(),
642            &format!("analytics source '{}' created", profile.name),
643            "create",
644        ))
645    }
646}
647
648fn query_control_event_specs(expr: &QueryExpr) -> Vec<QueryControlEventSpec> {
649    use crate::runtime::control_events::{EventKind, Sensitivity};
650
651    let mut specs = Vec::new();
652    let mut schema = |action: &'static str, resource: Option<String>| {
653        specs.push(QueryControlEventSpec {
654            kind: EventKind::SchemaDdl,
655            action,
656            resource,
657            fields: Vec::new(),
658        });
659    };
660    match expr {
661        QueryExpr::CreateTable(q) => {
662            schema("create_table", Some(format!("table:{}", q.name)));
663            if let Some(column) = &q.tenant_by {
664                specs.push(QueryControlEventSpec {
665                    kind: EventKind::TenantGovernance,
666                    action: "create_table_tenant_by",
667                    resource: Some(format!("table:{}", q.name)),
668                    fields: vec![("tenant_column".to_string(), Sensitivity::raw(column))],
669                });
670            }
671        }
672        QueryExpr::CreateCollection(q) => {
673            schema("create_collection", Some(format!("collection:{}", q.name)));
674        }
675        QueryExpr::CreateVector(q) => schema("create_vector", Some(format!("vector:{}", q.name))),
676        QueryExpr::DropTable(q) => schema("drop_table", Some(format!("table:{}", q.name))),
677        QueryExpr::DropGraph(q) => schema("drop_graph", Some(format!("graph:{}", q.name))),
678        QueryExpr::DropVector(q) => schema("drop_vector", Some(format!("vector:{}", q.name))),
679        QueryExpr::DropDocument(q) => {
680            schema("drop_document", Some(format!("document:{}", q.name)));
681        }
682        QueryExpr::DropKv(q) => schema("drop_kv", Some(format!("kv:{}", q.name))),
683        QueryExpr::DropCollection(q) => {
684            schema("drop_collection", Some(format!("collection:{}", q.name)));
685        }
686        QueryExpr::Truncate(q) => schema("truncate", Some(format!("collection:{}", q.name))),
687        QueryExpr::AlterTable(q) => {
688            schema("alter_table", Some(format!("table:{}", q.name)));
689            for op in &q.operations {
690                match op {
691                    crate::storage::query::ast::AlterOperation::EnableRowLevelSecurity => {
692                        specs.push(QueryControlEventSpec {
693                            kind: EventKind::RlsGovernance,
694                            action: "enable_rls",
695                            resource: Some(format!("table:{}", q.name)),
696                            fields: Vec::new(),
697                        });
698                    }
699                    crate::storage::query::ast::AlterOperation::DisableRowLevelSecurity => {
700                        specs.push(QueryControlEventSpec {
701                            kind: EventKind::RlsGovernance,
702                            action: "disable_rls",
703                            resource: Some(format!("table:{}", q.name)),
704                            fields: Vec::new(),
705                        });
706                    }
707                    crate::storage::query::ast::AlterOperation::EnableTenancy { column } => {
708                        specs.push(QueryControlEventSpec {
709                            kind: EventKind::TenantGovernance,
710                            action: "enable_tenancy",
711                            resource: Some(format!("table:{}", q.name)),
712                            fields: vec![("tenant_column".to_string(), Sensitivity::raw(column))],
713                        });
714                    }
715                    crate::storage::query::ast::AlterOperation::DisableTenancy => {
716                        specs.push(QueryControlEventSpec {
717                            kind: EventKind::TenantGovernance,
718                            action: "disable_tenancy",
719                            resource: Some(format!("table:{}", q.name)),
720                            fields: Vec::new(),
721                        });
722                    }
723                    _ => {}
724                }
725            }
726        }
727        QueryExpr::CreateVcsRef(q) => {
728            let kind = match q.kind {
729                crate::storage::query::ast::VcsRefKind::Branch => "branch",
730                crate::storage::query::ast::VcsRefKind::Tag => "tag",
731            };
732            schema("create_vcs_ref", Some(format!("{kind}:{}", q.name)));
733        }
734        QueryExpr::DropVcsRef(q) => {
735            let kind = match q.kind {
736                crate::storage::query::ast::VcsRefKind::Branch => "branch",
737                crate::storage::query::ast::VcsRefKind::Tag => "tag",
738            };
739            schema("drop_vcs_ref", Some(format!("{kind}:{}", q.name)));
740        }
741        QueryExpr::CreateIndex(q) => {
742            schema(
743                "create_index",
744                Some(format!("index:{}:{}", q.table, q.name)),
745            );
746        }
747        QueryExpr::DropIndex(q) => {
748            schema("drop_index", Some(format!("index:{}:{}", q.table, q.name)));
749        }
750        QueryExpr::CreateTimeSeries(q) => {
751            schema("create_timeseries", Some(format!("timeseries:{}", q.name)));
752        }
753        QueryExpr::CreateMetric(q) => {
754            schema("create_metric", Some(format!("metric:{}", q.path)));
755        }
756        QueryExpr::AlterMetric(q) => {
757            schema("alter_metric", Some(format!("metric:{}", q.path)));
758        }
759        QueryExpr::CreateSlo(q) => {
760            schema("create_slo", Some(format!("slo:{}", q.path)));
761        }
762        QueryExpr::DropTimeSeries(q) => {
763            schema("drop_timeseries", Some(format!("timeseries:{}", q.name)));
764        }
765        QueryExpr::CreateQueue(q) => schema("create_queue", Some(format!("queue:{}", q.name))),
766        QueryExpr::AlterQueue(q) => schema("alter_queue", Some(format!("queue:{}", q.name))),
767        QueryExpr::DropQueue(q) => schema("drop_queue", Some(format!("queue:{}", q.name))),
768        QueryExpr::CreateTree(q) => {
769            schema(
770                "create_tree",
771                Some(format!("tree:{}:{}", q.collection, q.name)),
772            );
773        }
774        QueryExpr::DropTree(q) => {
775            schema(
776                "drop_tree",
777                Some(format!("tree:{}:{}", q.collection, q.name)),
778            );
779        }
780        QueryExpr::CreateSchema(q) => schema("create_schema", Some(format!("schema:{}", q.name))),
781        QueryExpr::DropSchema(q) => schema("drop_schema", Some(format!("schema:{}", q.name))),
782        QueryExpr::CreateSequence(q) => {
783            schema("create_sequence", Some(format!("sequence:{}", q.name)));
784        }
785        QueryExpr::DropSequence(q) => schema("drop_sequence", Some(format!("sequence:{}", q.name))),
786        QueryExpr::CreateView(q) => schema("create_view", Some(format!("view:{}", q.name))),
787        QueryExpr::DropView(q) => schema("drop_view", Some(format!("view:{}", q.name))),
788        QueryExpr::RefreshMaterializedView(q) => {
789            schema(
790                "refresh_materialized_view",
791                Some(format!("view:{}", q.name)),
792            );
793        }
794        QueryExpr::CreatePolicy(q) => {
795            specs.push(QueryControlEventSpec {
796                kind: EventKind::RlsGovernance,
797                action: "create_policy",
798                resource: Some(format!("table:{}:policy:{}", q.table, q.name)),
799                fields: vec![(
800                    "target_kind".to_string(),
801                    Sensitivity::raw(q.target_kind.as_ident()),
802                )],
803            });
804        }
805        QueryExpr::DropPolicy(q) => {
806            specs.push(QueryControlEventSpec {
807                kind: EventKind::RlsGovernance,
808                action: "drop_policy",
809                resource: Some(format!("table:{}:policy:{}", q.table, q.name)),
810                fields: Vec::new(),
811            });
812        }
813        QueryExpr::SetTenant(value) => {
814            let mut fields = Vec::new();
815            if let Some(value) = value {
816                fields.push(("tenant".to_string(), Sensitivity::raw(value)));
817            }
818            specs.push(QueryControlEventSpec {
819                kind: EventKind::TenantGovernance,
820                action: "set_tenant",
821                resource: Some("tenant:session".to_string()),
822                fields,
823            });
824        }
825        QueryExpr::SetConfig { key, .. } => {
826            specs.push(QueryControlEventSpec {
827                kind: EventKind::ConfigWrite,
828                action: "config:write",
829                resource: Some(format!("config:{key}")),
830                fields: vec![("key".to_string(), Sensitivity::raw(key))],
831            });
832        }
833        QueryExpr::ConfigCommand(cmd) => match cmd {
834            crate::storage::query::ast::ConfigCommand::Put {
835                collection, key, ..
836            }
837            | crate::storage::query::ast::ConfigCommand::Rotate {
838                collection, key, ..
839            } => {
840                let target = format!("{collection}/{key}");
841                specs.push(QueryControlEventSpec {
842                    kind: EventKind::ConfigWrite,
843                    action: "config:write",
844                    resource: Some(format!("config:{target}")),
845                    fields: vec![
846                        ("collection".to_string(), Sensitivity::raw(collection)),
847                        ("key".to_string(), Sensitivity::raw(key)),
848                    ],
849                });
850            }
851            crate::storage::query::ast::ConfigCommand::Delete { collection, key } => {
852                let target = format!("{collection}/{key}");
853                specs.push(QueryControlEventSpec {
854                    kind: EventKind::ConfigDelete,
855                    action: "config:write",
856                    resource: Some(format!("config:{target}")),
857                    fields: vec![
858                        ("collection".to_string(), Sensitivity::raw(collection)),
859                        ("key".to_string(), Sensitivity::raw(key)),
860                    ],
861                });
862            }
863            _ => {}
864        },
865        QueryExpr::AlterUser(stmt) => {
866            let disables = stmt.attributes.iter().any(|attr| {
867                matches!(
868                    attr,
869                    crate::storage::query::ast::AlterUserAttribute::Disable
870                )
871            });
872            specs.push(QueryControlEventSpec {
873                kind: if disables {
874                    EventKind::UserDisable
875                } else {
876                    EventKind::UserUpdate
877                },
878                action: "alter_user",
879                resource: Some(format!("user:{}", stmt.username)),
880                fields: Vec::new(),
881            });
882        }
883        QueryExpr::CreateUser(stmt) => {
884            specs.push(QueryControlEventSpec {
885                kind: EventKind::UserCreate,
886                action: "create_user",
887                resource: Some(format!("user:{}", stmt.username)),
888                fields: Vec::new(),
889            });
890        }
891        _ => {}
892    }
893    specs
894}
895
896pub(crate) fn control_event_outcome_for_error(
897    err: &RedDBError,
898) -> crate::runtime::control_events::Outcome {
899    match err {
900        RedDBError::ReadOnly(_) => crate::runtime::control_events::Outcome::Denied,
901        RedDBError::Query(msg)
902            if msg.contains("permission denied")
903                || msg.contains("cannot issue")
904                || msg.contains("lacks") =>
905        {
906            crate::runtime::control_events::Outcome::Denied
907        }
908        _ => crate::runtime::control_events::Outcome::Error,
909    }
910}
911
912/// Convert the rows produced by a materialized-view body into
913/// `UnifiedEntity` table rows targeting the backing collection.
914/// Issue #595 slice 9c — feeds `UnifiedStore::refresh_collection`.
915///
916/// Graph fragments and vector hits are ignored: a materialized view
917/// is a relational result set (SELECT-shaped); slices 11+ may extend
918/// this once we have a richer view body shape. Each row materialises
919/// the union of its schema-bound columns + overflow.
920fn view_records_to_entities(
921    table: &str,
922    records: &[crate::storage::query::unified::UnifiedRecord],
923) -> Vec<crate::storage::UnifiedEntity> {
924    use std::collections::HashMap;
925    let table_arc: std::sync::Arc<str> = std::sync::Arc::from(table);
926    let mut out = Vec::with_capacity(records.len());
927    for record in records {
928        let mut named: HashMap<String, crate::storage::schema::Value> = HashMap::new();
929        for (name, value) in record.iter_fields() {
930            named.insert(name.to_string(), value.clone());
931        }
932        let entity = crate::storage::UnifiedEntity::new(
933            crate::storage::EntityId::new(0),
934            crate::storage::EntityKind::TableRow {
935                table: std::sync::Arc::clone(&table_arc),
936                row_id: 0,
937            },
938            crate::storage::EntityData::Row(crate::storage::RowData {
939                columns: Vec::new(),
940                named: Some(named),
941                schema: None,
942            }),
943        );
944        out.push(entity);
945    }
946    out
947}
948
949fn system_keyed_collection_contract(
950    name: &str,
951    model: crate::catalog::CollectionModel,
952) -> crate::physical::CollectionContract {
953    let now = crate::utils::now_unix_millis() as u128;
954    crate::physical::CollectionContract {
955        name: name.to_string(),
956        declared_model: model,
957        schema_mode: crate::catalog::SchemaMode::Dynamic,
958        origin: crate::physical::ContractOrigin::Implicit,
959        version: 1,
960        created_at_unix_ms: now,
961        updated_at_unix_ms: now,
962        default_ttl_ms: None,
963        vector_dimension: None,
964        vector_metric: None,
965        context_index_fields: Vec::new(),
966        declared_columns: Vec::new(),
967        table_def: None,
968        timestamps_enabled: false,
969        context_index_enabled: false,
970        metrics_raw_retention_ms: None,
971        metrics_rollup_policies: Vec::new(),
972        metrics_tenant_identity: None,
973        metrics_namespace: None,
974        append_only: false,
975        subscriptions: Vec::new(),
976        analytics_config: Vec::new(),
977        session_key: None,
978        session_gap_ms: None,
979        retention_duration_ms: None,
980        analytical_storage: None,
981
982        ai_policy: None,
983    }
984}
985
986pub use super::execution_context::{
987    capture_current_snapshot, clear_current_auth_identity, clear_current_connection_id,
988    clear_current_snapshot, clear_current_tenant, current_auth_identity_for_audit,
989    current_connection_id, current_tenant, entity_visible_under_current_snapshot,
990    entity_visible_with_context, set_current_auth_identity, set_current_connection_id,
991    set_current_snapshot, set_current_tenant, snapshot_bundle, with_snapshot_bundle,
992    SnapshotBundle, SnapshotContext,
993};
994pub(crate) use super::execution_context::{
995    current_auth_identity, current_config_value, current_kv_value, current_role_projected,
996    current_scope_override, current_secret_value, current_snapshot_requires_index_fallback,
997    current_user_projected, has_scope_override_active, parse_set_local_tenant,
998    update_current_config_value, update_current_kv_value, update_current_secret_value,
999    xids_visible_under_current_snapshot, ConfigSnapshotGuard, CurrentSnapshotGuard, KvStoreGuard,
1000    ScopeOverrideGuard, SecretStoreGuard, TxLocalTenantGuard,
1001};
1002
1003fn table_row_index_fields(
1004    entity: &crate::storage::unified::entity::UnifiedEntity,
1005) -> Vec<(String, crate::storage::schema::Value)> {
1006    let crate::storage::EntityData::Row(row) = &entity.data else {
1007        return Vec::new();
1008    };
1009    if let Some(named) = &row.named {
1010        return named
1011            .iter()
1012            .map(|(name, value)| (name.clone(), value.clone()))
1013            .collect();
1014    }
1015    if let Some(schema) = &row.schema {
1016        return schema
1017            .iter()
1018            .zip(row.columns.iter())
1019            .map(|(name, value)| (name.clone(), value.clone()))
1020            .collect();
1021    }
1022    Vec::new()
1023}
1024
1025fn named_text(
1026    named: &std::collections::HashMap<String, crate::storage::schema::Value>,
1027    key: &str,
1028) -> Option<String> {
1029    match named.get(key) {
1030        Some(crate::storage::schema::Value::Text(value)) => Some(value.to_string()),
1031        _ => None,
1032    }
1033}
1034
1035fn named_bool(
1036    named: &std::collections::HashMap<String, crate::storage::schema::Value>,
1037    key: &str,
1038) -> Option<bool> {
1039    match named.get(key) {
1040        Some(crate::storage::schema::Value::Boolean(value)) => Some(*value),
1041        _ => None,
1042    }
1043}
1044
1045fn named_i64(
1046    named: &std::collections::HashMap<String, crate::storage::schema::Value>,
1047    key: &str,
1048) -> Option<i64> {
1049    match named.get(key) {
1050        Some(crate::storage::schema::Value::Integer(value)) => Some(*value),
1051        _ => None,
1052    }
1053}
1054
1055fn index_method_kind_as_str(method: super::index_store::IndexMethodKind) -> &'static str {
1056    match method {
1057        super::index_store::IndexMethodKind::Hash => "hash",
1058        super::index_store::IndexMethodKind::Bitmap => "bitmap",
1059        super::index_store::IndexMethodKind::Spatial => "spatial",
1060        super::index_store::IndexMethodKind::BTree => "btree",
1061        // The H3 resolution rides in a sibling `resolution` column of the
1062        // persisted descriptor; the method tag itself is just "h3".
1063        super::index_store::IndexMethodKind::H3 { .. } => "h3",
1064    }
1065}
1066
1067/// The H3 resolution to persist for an index. Non-H3 kinds persist `0`,
1068/// which the rehydrate path ignores.
1069fn index_method_kind_resolution(method: super::index_store::IndexMethodKind) -> u8 {
1070    match method {
1071        super::index_store::IndexMethodKind::H3 { resolution } => resolution,
1072        _ => 0,
1073    }
1074}
1075
1076fn index_method_kind_from_str(
1077    raw: &str,
1078    resolution: u8,
1079) -> Option<super::index_store::IndexMethodKind> {
1080    match raw {
1081        "hash" => Some(super::index_store::IndexMethodKind::Hash),
1082        "bitmap" => Some(super::index_store::IndexMethodKind::Bitmap),
1083        "spatial" | "rtree" => Some(super::index_store::IndexMethodKind::Spatial),
1084        "btree" => Some(super::index_store::IndexMethodKind::BTree),
1085        "h3" => Some(super::index_store::IndexMethodKind::H3 { resolution }),
1086        _ => None,
1087    }
1088}
1089
1090fn runtime_pool_lock(runtime: &RedDBRuntime) -> std::sync::MutexGuard<'_, PoolState> {
1091    runtime
1092        .inner
1093        .pool
1094        .lock()
1095        .unwrap_or_else(|poisoned| poisoned.into_inner())
1096}
1097
1098fn json_runtime_value(value: crate::json::Value) -> Value {
1099    crate::json::to_vec(&value)
1100        .map(Value::Json)
1101        .unwrap_or(Value::Null)
1102}
1103
1104/// The graph-analytics table-valued functions recognized in FROM position.
1105/// Both the graph-collection form and the inline `nodes => / edges =>` form
1106/// (issue #799) accept these names.
1107fn is_graph_tvf_name(name: &str) -> bool {
1108    name.eq_ignore_ascii_case("components")
1109        || name.eq_ignore_ascii_case("louvain")
1110        || name.eq_ignore_ascii_case("degree_centrality")
1111        || name.eq_ignore_ascii_case("shortest_path")
1112        || name.eq_ignore_ascii_case("betweenness")
1113        || name.eq_ignore_ascii_case("eigenvector")
1114        || name.eq_ignore_ascii_case("pagerank")
1115}
1116
1117/// Map a declared `WITH ANALYTICS` view to the concrete graph algorithm name
1118/// and named-argument list that [`RedDBRuntime::dispatch_graph_algorithm`]
1119/// consumes (issue #800). The `using` option selects the algorithm inside the
1120/// output family; unsupported algorithms and the options that do not apply to
1121/// the chosen algorithm are rejected so a view never silently ignores a
1122/// declared parameter.
1123fn analytics_view_algorithm(
1124    graph: &str,
1125    view: &crate::catalog::AnalyticsViewDescriptor,
1126) -> RedDBResult<(String, Vec<(String, f64)>)> {
1127    use crate::catalog::AnalyticsOutput;
1128
1129    let mut named_args: Vec<(String, f64)> = Vec::new();
1130    let algorithm = match view.output {
1131        AnalyticsOutput::Communities => {
1132            let algo = view.algorithm.as_deref().unwrap_or("louvain");
1133            if !algo.eq_ignore_ascii_case("louvain") {
1134                return Err(RedDBError::Query(format!(
1135                    "analytics output 'communities' on graph '{graph}' has unsupported algorithm '{algo}' (expected louvain)"
1136                )));
1137            }
1138            if let Some(resolution) = view.resolution {
1139                named_args.push(("resolution".to_string(), resolution));
1140            }
1141            "louvain".to_string()
1142        }
1143        AnalyticsOutput::Components => {
1144            if let Some(algo) = view.algorithm.as_deref() {
1145                if !algo.eq_ignore_ascii_case("components")
1146                    && !algo.eq_ignore_ascii_case("connected_components")
1147                {
1148                    return Err(RedDBError::Query(format!(
1149                        "analytics output 'components' on graph '{graph}' has unsupported algorithm '{algo}' (expected connected_components)"
1150                    )));
1151                }
1152            }
1153            "components".to_string()
1154        }
1155        AnalyticsOutput::Centrality => {
1156            let algo = view
1157                .algorithm
1158                .as_deref()
1159                .unwrap_or("pagerank")
1160                .to_ascii_lowercase();
1161            match algo.as_str() {
1162                "pagerank" => {
1163                    if let Some(max_iterations) = view.max_iterations {
1164                        named_args.push(("max_iterations".to_string(), max_iterations as f64));
1165                    }
1166                }
1167                "eigenvector" => {
1168                    if let Some(max_iterations) = view.max_iterations {
1169                        named_args.push(("max_iterations".to_string(), max_iterations as f64));
1170                    }
1171                    if let Some(tolerance) = view.tolerance {
1172                        named_args.push(("tolerance".to_string(), tolerance));
1173                    }
1174                }
1175                "betweenness" => {}
1176                other => {
1177                    return Err(RedDBError::Query(format!(
1178                        "analytics output 'centrality' on graph '{graph}' has unsupported algorithm '{other}' (expected pagerank, betweenness, or eigenvector)"
1179                    )));
1180                }
1181            }
1182            algo
1183        }
1184    };
1185    Ok((algorithm, named_args))
1186}
1187
1188/// Reject any named arguments for a TVF that accepts none.
1189fn reject_named_args(name: &str, named_args: &[(String, f64)]) -> RedDBResult<()> {
1190    if let Some((key, _)) = named_args.first() {
1191        return Err(RedDBError::Query(format!(
1192            "table function '{name}' has no named argument '{key}'"
1193        )));
1194    }
1195    Ok(())
1196}
1197
1198/// Resolve louvain's optional `resolution` named arg (γ, default 1.0). Any
1199/// other named key, or a non-finite / non-positive resolution, is rejected.
1200fn louvain_resolution(named_args: &[(String, f64)]) -> RedDBResult<f64> {
1201    let mut resolution = 1.0_f64;
1202    for (key, value) in named_args {
1203        if key.eq_ignore_ascii_case("resolution") {
1204            if !value.is_finite() || *value <= 0.0 {
1205                return Err(RedDBError::Query(format!(
1206                    "table function 'louvain' resolution must be > 0, got {value}"
1207                )));
1208            }
1209            resolution = *value;
1210        } else {
1211            return Err(RedDBError::Query(format!(
1212                "table function 'louvain' has no named argument '{key}' (expected 'resolution')"
1213            )));
1214        }
1215    }
1216    Ok(resolution)
1217}
1218
1219/// Undirected degree centrality over abstract inputs: each edge contributes
1220/// 1 to both of its endpoints. Returns `(node_id, degree)` deterministically
1221/// in ascending node-id order, so identical input always yields identical
1222/// rows.
1223fn abstract_degree_centrality(
1224    nodes: &[String],
1225    edges: &[(
1226        String,
1227        String,
1228        crate::storage::engine::graph_algorithms::Weight,
1229    )],
1230) -> Vec<(String, usize)> {
1231    let mut degree: std::collections::BTreeMap<String, usize> = std::collections::BTreeMap::new();
1232    for n in nodes {
1233        degree.entry(n.clone()).or_insert(0);
1234    }
1235    for (a, b, _w) in edges {
1236        *degree.entry(a.clone()).or_insert(0) += 1;
1237        *degree.entry(b.clone()).or_insert(0) += 1;
1238    }
1239    degree.into_iter().collect()
1240}
1241
1242/// Ordered column names for a materialized subquery result: the projection
1243/// columns when present, else the first record's field order.
1244fn ordered_result_columns(result: &crate::storage::query::unified::UnifiedResult) -> Vec<String> {
1245    if !result.columns.is_empty() {
1246        return result.columns.clone();
1247    }
1248    result
1249        .records
1250        .first()
1251        .map(|record| {
1252            record
1253                .column_names()
1254                .iter()
1255                .map(|column| column.to_string())
1256                .collect()
1257        })
1258        .unwrap_or_default()
1259}
1260
1261/// Canonical node-id string for a cell value, so the node universe (from the
1262/// `nodes` subquery) and the edge endpoints (from the `edges` subquery)
1263/// compare equal regardless of integer-vs-text typing. `Null` is not a node.
1264fn value_to_node_id(value: &crate::storage::schema::Value) -> Option<String> {
1265    use crate::storage::schema::Value;
1266    match value {
1267        Value::Null => None,
1268        Value::Text(s) => Some(s.to_string()),
1269        Value::Integer(n) => Some(n.to_string()),
1270        Value::UnsignedInteger(n) => Some(n.to_string()),
1271        Value::NodeRef(s) => Some(s.clone()),
1272        other => Some(other.to_string()),
1273    }
1274}
1275
1276/// Numeric edge weight from a cell value (the optional third `edges` column).
1277fn value_to_weight(value: &crate::storage::schema::Value) -> Option<f32> {
1278    use crate::storage::schema::Value;
1279    match value {
1280        Value::Float(f) => Some(*f as f32),
1281        Value::Integer(n) => Some(*n as f32),
1282        Value::UnsignedInteger(n) => Some(*n as f32),
1283        _ => None,
1284    }
1285}
1286
1287/// Build the node universe from a materialized `nodes` subquery result: the
1288/// first projected column of each row is the node id (issue #799). Zero rows
1289/// is a valid empty node set; a row set with no columns is a shape error.
1290fn inline_node_ids(
1291    name: &str,
1292    result: &crate::storage::query::unified::UnifiedResult,
1293) -> RedDBResult<Vec<String>> {
1294    if result.records.is_empty() {
1295        return Ok(Vec::new());
1296    }
1297    let columns = ordered_result_columns(result);
1298    let Some(first_col) = columns.first() else {
1299        return Err(RedDBError::Query(format!(
1300            "table function '{name}' inline form: `nodes` subquery must project at least one column (the node id)"
1301        )));
1302    };
1303    let mut ids = Vec::with_capacity(result.records.len());
1304    for record in &result.records {
1305        if let Some(id) = record.get(first_col).and_then(value_to_node_id) {
1306            ids.push(id);
1307        }
1308    }
1309    Ok(ids)
1310}
1311
1312/// Build the edge list from a materialized `edges` subquery result: the first
1313/// two projected columns are `(source, target)` and an optional third column
1314/// is the numeric weight (defaulting to 1.0). Fewer than two columns is a
1315/// shape error (issue #799).
1316fn inline_edges(
1317    name: &str,
1318    result: &crate::storage::query::unified::UnifiedResult,
1319) -> RedDBResult<
1320    Vec<(
1321        String,
1322        String,
1323        crate::storage::engine::graph_algorithms::Weight,
1324    )>,
1325> {
1326    if result.records.is_empty() {
1327        return Ok(Vec::new());
1328    }
1329    let columns = ordered_result_columns(result);
1330    if columns.len() < 2 {
1331        return Err(RedDBError::Query(format!(
1332            "table function '{name}' inline form: `edges` subquery must project at least two columns (source, target), got {}",
1333            columns.len()
1334        )));
1335    }
1336    let src_col = &columns[0];
1337    let dst_col = &columns[1];
1338    let weight_col = columns.get(2);
1339    let mut edges = Vec::with_capacity(result.records.len());
1340    for record in &result.records {
1341        let (Some(src), Some(dst)) = (
1342            record.get(src_col).and_then(value_to_node_id),
1343            record.get(dst_col).and_then(value_to_node_id),
1344        ) else {
1345            // A null/absent endpoint is not a valid edge; skip it.
1346            continue;
1347        };
1348        let weight = match weight_col {
1349            Some(col) => match record.get(col) {
1350                None | Some(crate::storage::schema::Value::Null) => 1.0,
1351                Some(value) => value_to_weight(value).ok_or_else(|| {
1352                    RedDBError::Query(format!(
1353                        "table function '{name}' inline form: `edges` weight column must be numeric"
1354                    ))
1355                })?,
1356            },
1357            None => 1.0,
1358        };
1359        edges.push((src, dst, weight));
1360    }
1361    Ok(edges)
1362}
1363
1364fn cache_scope_insert(scopes: &mut HashSet<String>, name: &str) {
1365    if name.is_empty() || name.starts_with("__subq_") || is_universal_query_source(name) {
1366        return;
1367    }
1368    scopes.insert(name.to_string());
1369}
1370
1371fn collect_table_source_scopes(scopes: &mut HashSet<String>, query: &TableQuery) {
1372    match query.source.as_ref() {
1373        Some(crate::storage::query::ast::TableSource::Name(name)) => {
1374            cache_scope_insert(scopes, name)
1375        }
1376        Some(crate::storage::query::ast::TableSource::Subquery(subquery)) => {
1377            collect_query_expr_result_cache_scopes(scopes, subquery);
1378        }
1379        // Graph-collection TVFs (e.g. `louvain(g)`) read the graph store
1380        // read-only. The result is now cached (issue #802) and scoped to the
1381        // graph collection named in the first argument, so any mutation on
1382        // that collection (`INSERT INTO g NODE/EDGE …`) invalidates the
1383        // entry via `invalidate_result_cache_for_table`. Non-graph or
1384        // zero-arg functions contribute no scope.
1385        Some(crate::storage::query::ast::TableSource::Function { name, args, .. }) => {
1386            if is_graph_tvf_name(name) {
1387                if let Some(graph) = args.first() {
1388                    cache_scope_insert(scopes, graph);
1389                }
1390            }
1391        }
1392        // The inline-graph form reads ordinary tables/docs through its
1393        // `nodes`/`edges` subqueries, so its result cache must be scoped to
1394        // those source collections — mutating any of them invalidates the
1395        // cached result (issue #799).
1396        Some(crate::storage::query::ast::TableSource::InlineGraphFunction {
1397            nodes, edges, ..
1398        }) => {
1399            collect_query_expr_result_cache_scopes(scopes, nodes);
1400            collect_query_expr_result_cache_scopes(scopes, edges);
1401        }
1402        None => cache_scope_insert(scopes, &query.table),
1403    }
1404}
1405
1406fn collect_vector_source_scopes(
1407    scopes: &mut HashSet<String>,
1408    source: &crate::storage::query::ast::VectorSource,
1409) {
1410    match source {
1411        crate::storage::query::ast::VectorSource::Reference { collection, .. } => {
1412            cache_scope_insert(scopes, collection);
1413        }
1414        crate::storage::query::ast::VectorSource::Subquery(subquery) => {
1415            collect_query_expr_result_cache_scopes(scopes, subquery);
1416        }
1417        crate::storage::query::ast::VectorSource::Literal(_)
1418        | crate::storage::query::ast::VectorSource::Text(_) => {}
1419    }
1420}
1421
1422fn collect_path_selector_scopes(
1423    scopes: &mut HashSet<String>,
1424    selector: &crate::storage::query::ast::NodeSelector,
1425) {
1426    if let crate::storage::query::ast::NodeSelector::ByRow { table, .. } = selector {
1427        cache_scope_insert(scopes, table);
1428    }
1429}
1430
1431fn collect_query_expr_result_cache_scopes(scopes: &mut HashSet<String>, expr: &QueryExpr) {
1432    match expr {
1433        QueryExpr::Table(query) => collect_table_source_scopes(scopes, query),
1434        QueryExpr::Join(query) => {
1435            collect_query_expr_result_cache_scopes(scopes, &query.left);
1436            collect_query_expr_result_cache_scopes(scopes, &query.right);
1437        }
1438        QueryExpr::Path(query) => {
1439            collect_path_selector_scopes(scopes, &query.from);
1440            collect_path_selector_scopes(scopes, &query.to);
1441        }
1442        QueryExpr::Vector(query) => {
1443            cache_scope_insert(scopes, &query.collection);
1444            collect_vector_source_scopes(scopes, &query.query_vector);
1445        }
1446        QueryExpr::Hybrid(query) => {
1447            collect_query_expr_result_cache_scopes(scopes, &query.structured);
1448            cache_scope_insert(scopes, &query.vector.collection);
1449            collect_vector_source_scopes(scopes, &query.vector.query_vector);
1450        }
1451        QueryExpr::Insert(query) => cache_scope_insert(scopes, &query.table),
1452        QueryExpr::Update(query) => cache_scope_insert(scopes, &query.table),
1453        QueryExpr::Delete(query) => cache_scope_insert(scopes, &query.table),
1454        QueryExpr::CreateTable(query) => cache_scope_insert(scopes, &query.name),
1455        QueryExpr::CreateCollection(query) => cache_scope_insert(scopes, &query.name),
1456        QueryExpr::CreateVector(query) => cache_scope_insert(scopes, &query.name),
1457        QueryExpr::DropTable(query) => cache_scope_insert(scopes, &query.name),
1458        QueryExpr::DropGraph(query) => cache_scope_insert(scopes, &query.name),
1459        QueryExpr::DropVector(query) => cache_scope_insert(scopes, &query.name),
1460        QueryExpr::DropDocument(query) => cache_scope_insert(scopes, &query.name),
1461        QueryExpr::DropKv(query) => cache_scope_insert(scopes, &query.name),
1462        QueryExpr::DropCollection(query) => cache_scope_insert(scopes, &query.name),
1463        QueryExpr::Truncate(query) => cache_scope_insert(scopes, &query.name),
1464        QueryExpr::AlterTable(query) => cache_scope_insert(scopes, &query.name),
1465        QueryExpr::CreateVcsRef(_) | QueryExpr::DropVcsRef(_) => {
1466            cache_scope_insert(scopes, crate::application::vcs_collections::REFS)
1467        }
1468        QueryExpr::CreateIndex(query) => cache_scope_insert(scopes, &query.table),
1469        QueryExpr::DropIndex(query) => cache_scope_insert(scopes, &query.table),
1470        QueryExpr::CreateTimeSeries(query) => cache_scope_insert(scopes, &query.name),
1471        QueryExpr::CreateMetric(query) => cache_scope_insert(scopes, &query.path),
1472        QueryExpr::AlterMetric(query) => cache_scope_insert(scopes, &query.path),
1473        QueryExpr::CreateSlo(query) => cache_scope_insert(scopes, &query.path),
1474        QueryExpr::DropTimeSeries(query) => cache_scope_insert(scopes, &query.name),
1475        QueryExpr::CreateQueue(query) => cache_scope_insert(scopes, &query.name),
1476        QueryExpr::AlterQueue(query) => cache_scope_insert(scopes, &query.name),
1477        QueryExpr::DropQueue(query) => cache_scope_insert(scopes, &query.name),
1478        QueryExpr::QueueSelect(query) => cache_scope_insert(scopes, &query.queue),
1479        QueryExpr::QueueCommand(query) => match query {
1480            QueueCommand::Push { queue, .. }
1481            | QueueCommand::Pop { queue, .. }
1482            | QueueCommand::Peek { queue, .. }
1483            | QueueCommand::Len { queue }
1484            | QueueCommand::Purge { queue }
1485            | QueueCommand::GroupCreate { queue, .. }
1486            | QueueCommand::GroupRead { queue, .. }
1487            | QueueCommand::Pending { queue, .. }
1488            | QueueCommand::Claim { queue, .. }
1489            | QueueCommand::Ack { queue, .. }
1490            | QueueCommand::Nack { queue, .. } => cache_scope_insert(scopes, queue),
1491            QueueCommand::Move {
1492                source,
1493                destination,
1494                ..
1495            } => {
1496                cache_scope_insert(scopes, source);
1497                cache_scope_insert(scopes, destination);
1498            }
1499        },
1500        QueryExpr::EventsBackfill(query) => {
1501            cache_scope_insert(scopes, &query.collection);
1502            cache_scope_insert(scopes, &query.target_queue);
1503        }
1504        QueryExpr::CreateTree(query) => cache_scope_insert(scopes, &query.collection),
1505        QueryExpr::DropTree(query) => cache_scope_insert(scopes, &query.collection),
1506        QueryExpr::TreeCommand(query) => match query {
1507            TreeCommand::Insert { collection, .. }
1508            | TreeCommand::Move { collection, .. }
1509            | TreeCommand::Delete { collection, .. }
1510            | TreeCommand::Validate { collection, .. }
1511            | TreeCommand::Rebalance { collection, .. } => cache_scope_insert(scopes, collection),
1512        },
1513        QueryExpr::SearchCommand(query) => match query {
1514            SearchCommand::Similar { collection, .. }
1515            | SearchCommand::Hybrid { collection, .. }
1516            | SearchCommand::SpatialRadius { collection, .. }
1517            | SearchCommand::SpatialBbox { collection, .. }
1518            | SearchCommand::SpatialNearest { collection, .. } => {
1519                cache_scope_insert(scopes, collection);
1520            }
1521            SearchCommand::Text { collection, .. }
1522            | SearchCommand::Multimodal { collection, .. }
1523            | SearchCommand::Index { collection, .. }
1524            | SearchCommand::Context { collection, .. } => {
1525                if let Some(collection) = collection.as_deref() {
1526                    cache_scope_insert(scopes, collection);
1527                }
1528            }
1529        },
1530        QueryExpr::Ask(query) => {
1531            if let Some(collection) = query.collection.as_deref() {
1532                cache_scope_insert(scopes, collection);
1533            }
1534        }
1535        QueryExpr::ExplainAlter(query) => cache_scope_insert(scopes, &query.target.name),
1536        QueryExpr::MaintenanceCommand(cmd) => match cmd {
1537            crate::storage::query::ast::MaintenanceCommand::Vacuum { target, .. }
1538            | crate::storage::query::ast::MaintenanceCommand::Analyze { target } => {
1539                if let Some(t) = target {
1540                    cache_scope_insert(scopes, t);
1541                }
1542            }
1543        },
1544        QueryExpr::CopyFrom(cmd) => cache_scope_insert(scopes, &cmd.table),
1545        QueryExpr::CreateView(cmd) => {
1546            cache_scope_insert(scopes, &cmd.name);
1547            // Invalidating the view should also invalidate its dependencies.
1548            collect_query_expr_result_cache_scopes(scopes, &cmd.query);
1549        }
1550        QueryExpr::DropView(cmd) => cache_scope_insert(scopes, &cmd.name),
1551        QueryExpr::RefreshMaterializedView(cmd) => cache_scope_insert(scopes, &cmd.name),
1552        QueryExpr::CreatePolicy(cmd) => cache_scope_insert(scopes, &cmd.table),
1553        QueryExpr::DropPolicy(cmd) => cache_scope_insert(scopes, &cmd.table),
1554        QueryExpr::CreateServer(_) | QueryExpr::DropServer(_) => {}
1555        QueryExpr::CreateForeignTable(cmd) => cache_scope_insert(scopes, &cmd.name),
1556        QueryExpr::DropForeignTable(cmd) => cache_scope_insert(scopes, &cmd.name),
1557        QueryExpr::Graph(_)
1558        | QueryExpr::GraphCommand(_)
1559        | QueryExpr::ProbabilisticCommand(_)
1560        | QueryExpr::SetConfig { .. }
1561        | QueryExpr::ShowConfig { .. }
1562        | QueryExpr::SetSecret { .. }
1563        | QueryExpr::DeleteSecret { .. }
1564        | QueryExpr::ShowSecrets { .. }
1565        | QueryExpr::SetKv { .. }
1566        | QueryExpr::DeleteKv { .. }
1567        | QueryExpr::SetTenant(_)
1568        | QueryExpr::ShowTenant
1569        | QueryExpr::TransactionControl(_)
1570        | QueryExpr::CreateSchema(_)
1571        | QueryExpr::DropSchema(_)
1572        | QueryExpr::CreateSequence(_)
1573        | QueryExpr::DropSequence(_)
1574        | QueryExpr::Grant(_)
1575        | QueryExpr::Revoke(_)
1576        | QueryExpr::AlterUser(_)
1577        | QueryExpr::CreateUser(_)
1578        | QueryExpr::CreateIamPolicy { .. }
1579        | QueryExpr::DropIamPolicy { .. }
1580        | QueryExpr::AttachPolicy { .. }
1581        | QueryExpr::DetachPolicy { .. }
1582        | QueryExpr::ShowPolicies { .. }
1583        | QueryExpr::ShowEffectivePermissions { .. }
1584        | QueryExpr::RankOf(_)
1585        | QueryExpr::ApproxRankOf(_)
1586        | QueryExpr::RankRange(_)
1587        | QueryExpr::SimulatePolicy { .. }
1588        | QueryExpr::LintPolicy { .. }
1589        | QueryExpr::MigratePolicyMode { .. }
1590        | QueryExpr::CreateMigration(_)
1591        | QueryExpr::ApplyMigration(_)
1592        | QueryExpr::RollbackMigration(_)
1593        | QueryExpr::ExplainMigration(_)
1594        | QueryExpr::EventsBackfillStatus { .. } => {}
1595        QueryExpr::KvCommand(cmd) => {
1596            use crate::storage::query::ast::KvCommand;
1597            match cmd {
1598                KvCommand::Put { collection, .. }
1599                | KvCommand::InvalidateTags { collection, .. }
1600                | KvCommand::Get { collection, .. }
1601                | KvCommand::Unseal { collection, .. }
1602                | KvCommand::Rotate { collection, .. }
1603                | KvCommand::History { collection, .. }
1604                | KvCommand::List { collection, .. }
1605                | KvCommand::Purge { collection, .. }
1606                | KvCommand::Watch { collection, .. }
1607                | KvCommand::Delete { collection, .. }
1608                | KvCommand::Incr { collection, .. }
1609                | KvCommand::Cas { collection, .. } => cache_scope_insert(scopes, collection),
1610            }
1611        }
1612        QueryExpr::ConfigCommand(cmd) => {
1613            use crate::storage::query::ast::ConfigCommand;
1614            match cmd {
1615                ConfigCommand::Put { collection, .. }
1616                | ConfigCommand::Get { collection, .. }
1617                | ConfigCommand::Resolve { collection, .. }
1618                | ConfigCommand::Rotate { collection, .. }
1619                | ConfigCommand::Delete { collection, .. }
1620                | ConfigCommand::History { collection, .. }
1621                | ConfigCommand::List { collection, .. }
1622                | ConfigCommand::Watch { collection, .. }
1623                | ConfigCommand::InvalidVolatileOperation { collection, .. } => {
1624                    cache_scope_insert(scopes, collection)
1625                }
1626            }
1627        }
1628        _ => {}
1629    }
1630}
1631
1632/// Combine matching RLS policies for a table + action into a single
1633/// `Filter` suitable for AND-ing into a caller's `WHERE` clause.
1634///
1635/// Returns `None` when RLS is disabled or no policy admits the caller's
1636/// role — callers use that to short-circuit the mutation (for DELETE /
1637/// UPDATE we simply skip the operation, which PG expresses as "no rows
1638/// match the policy + predicate combination").
1639pub(crate) fn rls_policy_filter(
1640    runtime: &RedDBRuntime,
1641    table: &str,
1642    action: crate::storage::query::ast::PolicyAction,
1643) -> Option<crate::storage::query::ast::Filter> {
1644    rls_policy_filter_for_kind(
1645        runtime,
1646        table,
1647        action,
1648        crate::storage::query::ast::PolicyTargetKind::Table,
1649    )
1650}
1651
1652/// Kind-aware policy filter combiner (Phase 2.5.5 RLS universal).
1653/// Graph / vector / queue / timeseries scans pass the concrete kind;
1654/// policies targeting other kinds are ignored. Legacy Table-scoped
1655/// policies still apply cross-kind — callers register auto-tenancy
1656/// policies as Table today.
1657pub(crate) fn rls_policy_filter_for_kind(
1658    runtime: &RedDBRuntime,
1659    table: &str,
1660    action: crate::storage::query::ast::PolicyAction,
1661    kind: crate::storage::query::ast::PolicyTargetKind,
1662) -> Option<crate::storage::query::ast::Filter> {
1663    use crate::storage::query::ast::Filter;
1664
1665    if !runtime.inner.rls_enabled_tables.read().contains(table) {
1666        return None;
1667    }
1668    let role = current_auth_identity().map(|(_, role)| role);
1669    let role_str = role.map(|r| r.as_str().to_string());
1670    let policies = runtime.matching_rls_policies_for_kind(table, role_str.as_deref(), action, kind);
1671    if policies.is_empty() {
1672        return None;
1673    }
1674    policies
1675        .into_iter()
1676        .reduce(|acc, f| Filter::Or(Box::new(acc), Box::new(f)))
1677}
1678
1679/// Returns true when the table has RLS enforcement enabled. Convenience
1680/// shortcut so DML paths can gate the AND-combine work without reaching
1681/// into `runtime.inner.rls_enabled_tables` directly.
1682pub(crate) fn rls_is_enabled(runtime: &RedDBRuntime, table: &str) -> bool {
1683    runtime.inner.rls_enabled_tables.read().contains(table)
1684}
1685
1686/// Per-entity gate used by the graph materialiser for `GraphNode`
1687/// entities. RLS is checked against the source collection with
1688/// `kind = Nodes`, which `matching_rls_policies_for_kind` resolves to
1689/// either `Nodes`-targeted policies or legacy `Table`-targeted ones
1690/// (for back-compat with auto-tenancy declarations). Cached per
1691/// collection so big graphs only resolve the policy chain once.
1692fn node_passes_rls(
1693    runtime: &RedDBRuntime,
1694    collection: &str,
1695    role: Option<&str>,
1696    cache: &mut std::collections::HashMap<String, Option<crate::storage::query::ast::Filter>>,
1697    entity: &crate::storage::unified::entity::UnifiedEntity,
1698) -> bool {
1699    use crate::storage::query::ast::{Filter, PolicyAction, PolicyTargetKind};
1700
1701    if !runtime.inner.rls_enabled_tables.read().contains(collection) {
1702        return true;
1703    }
1704    let filter = cache.entry(collection.to_string()).or_insert_with(|| {
1705        let policies = runtime.matching_rls_policies_for_kind(
1706            collection,
1707            role,
1708            PolicyAction::Select,
1709            PolicyTargetKind::Nodes,
1710        );
1711        if policies.is_empty() {
1712            None
1713        } else {
1714            policies
1715                .into_iter()
1716                .reduce(|acc, f| Filter::Or(Box::new(acc), Box::new(f)))
1717        }
1718    });
1719    let Some(filter) = filter else {
1720        return false;
1721    };
1722    crate::runtime::query_exec::evaluate_entity_filter_with_db(
1723        Some(&runtime.inner.db),
1724        entity,
1725        filter,
1726        collection,
1727        collection,
1728    )
1729}
1730
1731/// Edge counterpart of `node_passes_rls`. Same caching strategy with
1732/// `kind = Edges`.
1733fn edge_passes_rls(
1734    runtime: &RedDBRuntime,
1735    collection: &str,
1736    role: Option<&str>,
1737    cache: &mut std::collections::HashMap<String, Option<crate::storage::query::ast::Filter>>,
1738    entity: &crate::storage::unified::entity::UnifiedEntity,
1739) -> bool {
1740    use crate::storage::query::ast::{Filter, PolicyAction, PolicyTargetKind};
1741
1742    if !runtime.inner.rls_enabled_tables.read().contains(collection) {
1743        return true;
1744    }
1745    let filter = cache.entry(collection.to_string()).or_insert_with(|| {
1746        let policies = runtime.matching_rls_policies_for_kind(
1747            collection,
1748            role,
1749            PolicyAction::Select,
1750            PolicyTargetKind::Edges,
1751        );
1752        if policies.is_empty() {
1753            None
1754        } else {
1755            policies
1756                .into_iter()
1757                .reduce(|acc, f| Filter::Or(Box::new(acc), Box::new(f)))
1758        }
1759    });
1760    let Some(filter) = filter else {
1761        return false;
1762    };
1763    crate::runtime::query_exec::evaluate_entity_filter_with_db(
1764        Some(&runtime.inner.db),
1765        entity,
1766        filter,
1767        collection,
1768        collection,
1769    )
1770}
1771
1772/// RLS policy injection (Phase 2.5.2 PG parity).
1773///
1774/// Fetch every matching policy for the current thread-local role and
1775/// fold them into the query's filter. Semantics mirror PostgreSQL:
1776///
1777/// * Multiple policies on the same table combine with **OR** — a row is
1778///   visible if *any* policy admits it.
1779/// * The combined policy predicate is **AND**-ed into the caller's
1780///   existing `WHERE` clause so explicit predicates continue to trim
1781///   the policy-allowed set.
1782/// * No matching policies + RLS enabled = zero rows (PG's
1783///   restrictive-default). Callers get `None` and return an empty
1784///   `UnifiedResult` without ever dispatching the scan.
1785///
1786/// This runs only when `RuntimeInner::rls_enabled_tables` already
1787/// contains the table name — callers gate the hot path upfront to
1788/// avoid the lock acquisition on tables without RLS.
1789///
1790/// Returns `None` when no policy admits the current role; returns
1791/// `Some(mutated_table)` with policy filters folded in otherwise.
1792fn inject_rls_filters(
1793    runtime: &RedDBRuntime,
1794    frame: &dyn super::statement_frame::ReadFrame,
1795    mut table: crate::storage::query::ast::TableQuery,
1796) -> Option<crate::storage::query::ast::TableQuery> {
1797    use crate::storage::query::ast::{Filter, PolicyAction};
1798
1799    // `None` role falls through to policies with no `TO role` clause.
1800    let role = frame.identity().map(|(_, role)| role);
1801    let role_str = role.map(|r| r.as_str().to_string());
1802    let policies =
1803        runtime.matching_rls_policies(&table.table, role_str.as_deref(), PolicyAction::Select);
1804
1805    if policies.is_empty() {
1806        // RLS enabled + no policy match = deny everything. Signal the
1807        // caller to short-circuit with an empty result set.
1808        return None;
1809    }
1810
1811    // Combine policy predicates with OR (PG's permissive default).
1812    let combined = policies
1813        .into_iter()
1814        .reduce(|acc, f| Filter::Or(Box::new(acc), Box::new(f)))
1815        .expect("policies non-empty");
1816
1817    // AND into the caller's existing predicate. The predicate may live
1818    // in `where_expr` rather than `filter`: `resolve_table_expr_subqueries`
1819    // nulls `filter` whenever `where_expr` is present (the case for a
1820    // view body rewritten into `SELECT … WHERE …`). Folding only into
1821    // `filter` here would silently drop that `where_expr` predicate at
1822    // eval time because `effective_table_filter` prefers `filter` —
1823    // e.g. `WITHIN TENANT … SELECT * FROM <view>` would apply the
1824    // tenant policy but lose the view's own WHERE (#635).
1825    use crate::storage::query::sql_lowering::{expr_to_filter, filter_to_expr};
1826    let had_where_expr = table.where_expr.is_some();
1827    let existing = table
1828        .filter
1829        .take()
1830        .or_else(|| table.where_expr.as_ref().map(expr_to_filter));
1831    let new_filter = match existing {
1832        Some(existing) => Filter::And(Box::new(existing), Box::new(combined)),
1833        None => combined,
1834    };
1835    // Keep `where_expr` in lock-step with the merged `filter` so
1836    // whichever the executor consults sees the full predicate.
1837    if had_where_expr {
1838        table.where_expr = Some(filter_to_expr(&new_filter));
1839    }
1840    table.filter = Some(new_filter);
1841    Some(table)
1842}
1843
1844/// Apply per-table RLS to a `JoinQuery` by folding each side's policy
1845/// predicate into the join's outer filter. Walking the merged record
1846/// at the join layer (rather than mutating the per-side scan filter)
1847/// keeps the planner's strategy choice and per-side index selection
1848/// undisturbed — the policy predicate uses the qualified `t.col` form
1849/// that resolves cleanly against the merged record's keys.
1850///
1851/// Returns `None` when any leaf has RLS enabled and no policy admits
1852/// the caller — the join short-circuits to an empty result.
1853fn inject_rls_into_join(
1854    runtime: &RedDBRuntime,
1855    frame: &dyn super::statement_frame::ReadFrame,
1856    mut join: crate::storage::query::ast::JoinQuery,
1857) -> Option<crate::storage::query::ast::JoinQuery> {
1858    use crate::storage::query::ast::Filter;
1859
1860    let mut policy_filters: Vec<Filter> = Vec::new();
1861    if !collect_join_side_policy(runtime, frame, join.left.as_ref(), &mut policy_filters) {
1862        return None;
1863    }
1864    if !collect_join_side_policy(runtime, frame, join.right.as_ref(), &mut policy_filters) {
1865        return None;
1866    }
1867
1868    if policy_filters.is_empty() {
1869        return Some(join);
1870    }
1871
1872    let combined = policy_filters
1873        .into_iter()
1874        .reduce(|acc, f| Filter::And(Box::new(acc), Box::new(f)))
1875        .expect("policy_filters non-empty");
1876
1877    join.filter = Some(match join.filter.take() {
1878        Some(existing) => Filter::And(Box::new(existing), Box::new(combined)),
1879        None => combined,
1880    });
1881
1882    Some(join)
1883}
1884
1885/// For each `Table` leaf reachable through nested joins, append the
1886/// RLS-policy filter (combined with OR across that side's matching
1887/// policies) into `out`. Returns `false` when a side has RLS enabled
1888/// but no policy admits the caller — the join must short-circuit.
1889fn collect_join_side_policy(
1890    runtime: &RedDBRuntime,
1891    frame: &dyn super::statement_frame::ReadFrame,
1892    expr: &crate::storage::query::ast::QueryExpr,
1893    out: &mut Vec<crate::storage::query::ast::Filter>,
1894) -> bool {
1895    use crate::storage::query::ast::{Filter, PolicyAction, QueryExpr};
1896    match expr {
1897        QueryExpr::Table(t) => {
1898            if !runtime.inner.rls_enabled_tables.read().contains(&t.table) {
1899                return true;
1900            }
1901            let role = frame.identity().map(|(_, role)| role);
1902            let role_str = role.map(|r| r.as_str().to_string());
1903            let policies =
1904                runtime.matching_rls_policies(&t.table, role_str.as_deref(), PolicyAction::Select);
1905            if policies.is_empty() {
1906                return false;
1907            }
1908            let combined = policies
1909                .into_iter()
1910                .reduce(|acc, f| Filter::Or(Box::new(acc), Box::new(f)))
1911                .expect("policies non-empty");
1912            out.push(combined);
1913            true
1914        }
1915        QueryExpr::Join(inner) => {
1916            collect_join_side_policy(runtime, frame, inner.left.as_ref(), out)
1917                && collect_join_side_policy(runtime, frame, inner.right.as_ref(), out)
1918        }
1919        _ => true,
1920    }
1921}
1922
1923/// Foreign-table post-scan filter application (Phase 3.2.2 PG parity).
1924///
1925/// Phase 3.2 FDW wrappers don't advertise filter pushdown, so the runtime
1926/// applies `WHERE` / `ORDER BY` / `LIMIT` / `OFFSET` after the wrapper
1927/// materialises all rows. Projections are best-effort — when the query
1928/// lists explicit columns we keep only those; a `SELECT *` keeps every
1929/// wrapper-emitted field verbatim.
1930///
1931/// When a wrapper later opts into pushdown (`supports_pushdown = true`)
1932/// the runtime will pass the compiled filter down instead of post-filtering.
1933fn apply_foreign_table_filters(
1934    records: Vec<crate::storage::query::unified::UnifiedRecord>,
1935    query: &crate::storage::query::ast::TableQuery,
1936) -> crate::storage::query::unified::UnifiedResult {
1937    use crate::storage::query::sql_lowering::{
1938        effective_table_filter, effective_table_projections,
1939    };
1940    use crate::storage::query::unified::UnifiedResult;
1941
1942    let filter = effective_table_filter(query);
1943    let projections = effective_table_projections(query);
1944
1945    // Step 1 — WHERE. Reuse the cross-store evaluator so the semantics
1946    // match native-collection queries (same operators, same NULL handling).
1947    let mut filtered: Vec<_> = records
1948        .into_iter()
1949        .filter(|record| match &filter {
1950            Some(f) => {
1951                super::join_filter::evaluate_runtime_filter_with_db(None, record, f, None, None)
1952            }
1953            None => true,
1954        })
1955        .collect();
1956
1957    // Step 2 — LIMIT / OFFSET. Applied after filter to match SQL semantics.
1958    if let Some(offset) = query.offset {
1959        let offset = offset as usize;
1960        if offset >= filtered.len() {
1961            filtered.clear();
1962        } else {
1963            filtered.drain(0..offset);
1964        }
1965    }
1966    if let Some(limit) = query.limit {
1967        filtered.truncate(limit as usize);
1968    }
1969
1970    // Step 3 — columns list. `SELECT *` (no explicit projections) keeps
1971    // the wrapper's column set; an explicit list trims to those names.
1972    let columns: Vec<String> = if projections.is_empty() {
1973        filtered
1974            .first()
1975            .map(|r| r.column_names().iter().map(|k| k.to_string()).collect())
1976            .unwrap_or_default()
1977    } else {
1978        projections
1979            .iter()
1980            .map(super::join_filter::projection_name)
1981            .collect()
1982    };
1983
1984    let mut result = UnifiedResult::empty();
1985    result.columns = columns;
1986    result.records = filtered;
1987    result
1988}
1989
1990/// Collect every concrete table reference inside a `QueryExpr`.
1991///
1992/// Used by view bookkeeping (dependency tracking for materialised
1993/// invalidation) and any other rewriter that needs to know the base
1994/// tables a query pulls from. Does not descend into projections/filters;
1995/// only the `FROM` side.
1996pub(crate) fn collect_table_refs(expr: &QueryExpr) -> Vec<String> {
1997    let mut scopes: HashSet<String> = HashSet::new();
1998    collect_query_expr_result_cache_scopes(&mut scopes, expr);
1999    scopes.into_iter().collect()
2000}
2001
2002fn query_expr_result_cache_scopes(expr: &QueryExpr) -> HashSet<String> {
2003    let mut scopes = HashSet::new();
2004    collect_query_expr_result_cache_scopes(&mut scopes, expr);
2005    scopes
2006}
2007
2008/// Heuristic: does the raw SQL reference a built-in whose output
2009/// varies by connection, clock, or randomness? Such queries must
2010/// skip the 30s result cache — see the call site for rationale.
2011///
2012/// ASCII case-insensitive substring match. False positives (the
2013/// token appears in a quoted string) only skip caching, which is
2014/// the conservative direction.
2015/// If `sql` starts with `EXPLAIN` followed by a generic explainable statement,
2016/// return the trimmed inner statement; otherwise `None`.
2017///
2018/// `EXPLAIN ALTER FOR CREATE TABLE ...` is a separate schema-diff
2019/// command handled inside the normal SQL parser, so we leave it
2020/// alone here. `EXPLAIN ASK` and `EXPLAIN MIGRATION` are also executable
2021/// read paths handled by the parser/runtime directly.
2022fn strip_explain_prefix(sql: &str) -> Option<&str> {
2023    let trimmed = sql.trim_start();
2024    let (head, rest) = trimmed.split_at(
2025        trimmed
2026            .find(|c: char| c.is_whitespace())
2027            .unwrap_or(trimmed.len()),
2028    );
2029    if !head.eq_ignore_ascii_case("EXPLAIN") {
2030        return None;
2031    }
2032    let rest = rest.trim_start();
2033    if rest.is_empty() {
2034        return None;
2035    }
2036    // Peek the next token; command-specific EXPLAIN forms defer to
2037    // the normal parser.
2038    let next_head_end = rest.find(|c: char| c.is_whitespace()).unwrap_or(rest.len());
2039    if rest[..next_head_end].eq_ignore_ascii_case("ALTER")
2040        || rest[..next_head_end].eq_ignore_ascii_case("ASK")
2041        || rest[..next_head_end].eq_ignore_ascii_case("MIGRATION")
2042    {
2043        return None;
2044    }
2045    Some(rest)
2046}
2047
2048fn parse_vcs_author(raw: &str) -> crate::application::vcs::Author {
2049    let trimmed = raw.trim();
2050    if let Some((name, rest)) = trimmed.rsplit_once('<') {
2051        if let Some(email) = rest.strip_suffix('>') {
2052            return crate::application::vcs::Author {
2053                name: name.trim().to_string(),
2054                email: email.trim().to_string(),
2055            };
2056        }
2057    }
2058    crate::application::vcs::Author {
2059        name: trimmed.to_string(),
2060        email: String::new(),
2061    }
2062}
2063
2064fn looks_like_commit_hash(value: &str) -> bool {
2065    value.len() >= 7 && value.bytes().all(|byte| byte.is_ascii_hexdigit())
2066}
2067
2068enum RuntimeVcsCommand {
2069    Checkpoint {
2070        message: String,
2071        author: Option<String>,
2072    },
2073    Checkout {
2074        target: String,
2075    },
2076    Reset {
2077        mode: RuntimeVcsResetMode,
2078        target: String,
2079    },
2080    Merge {
2081        branch: String,
2082    },
2083    CherryPick {
2084        commit: String,
2085    },
2086    Revert {
2087        commit: String,
2088    },
2089    ResolveConflict {
2090        key: String,
2091        resolution: RuntimeVcsConflictResolution,
2092    },
2093}
2094
2095enum RuntimeVcsResetMode {
2096    Hard,
2097    Soft,
2098    Mixed,
2099}
2100
2101enum RuntimeVcsConflictResolution {
2102    Ours,
2103    Theirs,
2104}
2105
2106fn strip_keyword_ci<'a>(input: &'a str, keyword: &str) -> Option<&'a str> {
2107    let trimmed = input.trim_start();
2108    if trimmed.len() < keyword.len() || !trimmed[..keyword.len()].eq_ignore_ascii_case(keyword) {
2109        return None;
2110    }
2111    let rest = &trimmed[keyword.len()..];
2112    if rest.is_empty() || rest.starts_with(char::is_whitespace) {
2113        Some(rest.trim_start())
2114    } else {
2115        None
2116    }
2117}
2118
2119fn parse_vcs_quoted(input: &str) -> RedDBResult<(String, &str)> {
2120    let trimmed = input.trim_start();
2121    let rest = trimmed
2122        .strip_prefix('\'')
2123        .ok_or_else(|| RedDBError::Query("expected quoted string".to_string()))?;
2124    let end = rest
2125        .find('\'')
2126        .ok_or_else(|| RedDBError::Query("unterminated quoted string".to_string()))?;
2127    Ok((rest[..end].to_string(), rest[end + 1..].trim_start()))
2128}
2129
2130fn parse_vcs_atom(input: &str) -> RedDBResult<(String, &str)> {
2131    let trimmed = input.trim_start();
2132    if trimmed.starts_with('\'') {
2133        return parse_vcs_quoted(trimmed);
2134    }
2135    let end = trimmed.find(char::is_whitespace).unwrap_or(trimmed.len());
2136    if end == 0 {
2137        return Err(RedDBError::Query("expected VCS argument".to_string()));
2138    }
2139    Ok((trimmed[..end].to_string(), trimmed[end..].trim_start()))
2140}
2141
2142fn expect_vcs_end(rest: &str) -> RedDBResult<()> {
2143    if rest.trim().is_empty() {
2144        Ok(())
2145    } else {
2146        Err(RedDBError::Query(format!(
2147            "unexpected token after VCS command: {}",
2148            rest.trim()
2149        )))
2150    }
2151}
2152
2153fn parse_runtime_vcs_command(query: &str) -> Option<RedDBResult<RuntimeVcsCommand>> {
2154    let trimmed = query.trim_start();
2155    if let Some(rest) = strip_keyword_ci(trimmed, "CHECKPOINT") {
2156        return Some((|| {
2157            let (message, rest) = parse_vcs_quoted(rest)?;
2158            let rest = rest.trim_start();
2159            let author = if let Some(author_rest) = strip_keyword_ci(rest, "AUTHOR") {
2160                let (author, rest) = parse_vcs_quoted(author_rest)?;
2161                expect_vcs_end(rest)?;
2162                Some(author)
2163            } else {
2164                expect_vcs_end(rest)?;
2165                None
2166            };
2167            Ok(RuntimeVcsCommand::Checkpoint { message, author })
2168        })());
2169    }
2170    if let Some(rest) = strip_keyword_ci(trimmed, "CHECKOUT") {
2171        return Some((|| {
2172            let (target, rest) = parse_vcs_atom(rest)?;
2173            expect_vcs_end(rest)?;
2174            Ok(RuntimeVcsCommand::Checkout { target })
2175        })());
2176    }
2177    if let Some(rest) = strip_keyword_ci(trimmed, "RESET") {
2178        if strip_keyword_ci(rest, "TENANT").is_some() {
2179            return None;
2180        }
2181        return Some((|| {
2182            let mut rest = rest.trim_start();
2183            let mode = if let Some(next) = strip_keyword_ci(rest, "HARD") {
2184                rest = next;
2185                RuntimeVcsResetMode::Hard
2186            } else if let Some(next) = strip_keyword_ci(rest, "SOFT") {
2187                rest = next;
2188                RuntimeVcsResetMode::Soft
2189            } else if let Some(next) = strip_keyword_ci(rest, "MIXED") {
2190                rest = next;
2191                RuntimeVcsResetMode::Mixed
2192            } else {
2193                RuntimeVcsResetMode::Mixed
2194            };
2195            let rest = strip_keyword_ci(rest, "TO")
2196                .ok_or_else(|| RedDBError::Query("expected TO in RESET".to_string()))?;
2197            let (target, rest) = parse_vcs_atom(rest)?;
2198            expect_vcs_end(rest)?;
2199            Ok(RuntimeVcsCommand::Reset { mode, target })
2200        })());
2201    }
2202    if let Some(rest) = strip_keyword_ci(trimmed, "MERGE") {
2203        return Some((|| {
2204            let (branch, rest) = parse_vcs_atom(rest)?;
2205            expect_vcs_end(rest)?;
2206            Ok(RuntimeVcsCommand::Merge { branch })
2207        })());
2208    }
2209    if let Some(rest) = strip_keyword_ci(trimmed, "CHERRY") {
2210        return Some((|| {
2211            let rest = strip_keyword_ci(rest, "PICK")
2212                .ok_or_else(|| RedDBError::Query("expected PICK in CHERRY PICK".to_string()))?;
2213            let (commit, rest) = parse_vcs_atom(rest)?;
2214            expect_vcs_end(rest)?;
2215            Ok(RuntimeVcsCommand::CherryPick { commit })
2216        })());
2217    }
2218    if let Some(rest) = strip_keyword_ci(trimmed, "REVERT") {
2219        return Some((|| {
2220            let (commit, rest) = parse_vcs_atom(rest)?;
2221            expect_vcs_end(rest)?;
2222            Ok(RuntimeVcsCommand::Revert { commit })
2223        })());
2224    }
2225    if let Some(rest) = strip_keyword_ci(trimmed, "RESOLVE") {
2226        // Only `RESOLVE CONFLICT …` is a VCS working-set verb. Plain RESOLVE /
2227        // `RESOLVE CONFIG …` (config secret resolution) must fall through to the
2228        // normal query surface — the `?` early-returns None for non-CONFLICT,
2229        // mirroring the RESET/TENANT disambiguation above.
2230        let rest = strip_keyword_ci(rest, "CONFLICT")?;
2231        return Some((|| {
2232            let (key, rest) = parse_vcs_quoted(rest)?;
2233            let rest = strip_keyword_ci(rest, "USING")
2234                .ok_or_else(|| RedDBError::Query("expected USING in RESOLVE".to_string()))?;
2235            let (resolution, rest) = if let Some(rest) = strip_keyword_ci(rest, "OURS") {
2236                (RuntimeVcsConflictResolution::Ours, rest)
2237            } else if let Some(rest) = strip_keyword_ci(rest, "THEIRS") {
2238                (RuntimeVcsConflictResolution::Theirs, rest)
2239            } else {
2240                return Err(RedDBError::Query(
2241                    "expected OURS or THEIRS in RESOLVE".to_string(),
2242                ));
2243            };
2244            expect_vcs_end(rest)?;
2245            Ok(RuntimeVcsCommand::ResolveConflict { key, resolution })
2246        })());
2247    }
2248    None
2249}
2250
2251/// Cheap prefix check for a leading `WITH` keyword. Used to gate the
2252/// CTE-aware parse in `execute_query` without paying for a full
2253/// lexer pass on every statement. Treats `WITHIN` as not-a-CTE so
2254/// `WITHIN TENANT '...' SELECT ...` doesn't mis-route.
2255pub(super) fn has_with_prefix(sql: &str) -> bool {
2256    let trimmed = sql.trim_start();
2257    let head_end = trimmed
2258        .find(|c: char| c.is_whitespace() || c == '(')
2259        .unwrap_or(trimmed.len());
2260    trimmed[..head_end].eq_ignore_ascii_case("WITH")
2261}
2262
2263/// If the query is a plain SELECT whose top-level `TableQuery`
2264/// carries an `AS OF` clause, return a typed spec that the runtime
2265/// can feed to `vcs_resolve_as_of`. Returns `None` for any other
2266/// shape — joins, DML, EXPLAIN, or parse failures — so callers fall
2267/// back to the connection's regular MVCC snapshot. A cheap textual
2268/// prefilter skips the parse entirely when the source doesn't
2269/// mention `AS OF` / `as of`, keeping the autocommit hot path free.
2270fn peek_top_level_as_of(sql: &str) -> Option<crate::application::vcs::AsOfSpec> {
2271    peek_top_level_as_of_with_table(sql).map(|(spec, _)| spec)
2272}
2273
2274/// Same as `peek_top_level_as_of` but also returns the table name
2275/// targeted by the AS OF clause (when the FROM clause names a
2276/// concrete table). `None` for the table slot means scalar SELECT
2277/// or a subquery source — callers treat those as "no enforcement".
2278pub(super) fn peek_top_level_as_of_with_table(
2279    sql: &str,
2280) -> Option<(crate::application::vcs::AsOfSpec, Option<String>)> {
2281    if !sql
2282        .as_bytes()
2283        .windows(5)
2284        .any(|w| w.eq_ignore_ascii_case(b"as of"))
2285    {
2286        return None;
2287    }
2288    let parsed = crate::storage::query::parser::parse(sql).ok()?;
2289    let crate::storage::query::ast::QueryExpr::Table(table) = parsed.query else {
2290        return None;
2291    };
2292    let clause = table.as_of?;
2293    let table_name = if table.table.is_empty() || table.table == "any" {
2294        None
2295    } else {
2296        Some(table.table.clone())
2297    };
2298    let spec = match clause {
2299        crate::storage::query::ast::AsOfClause::Commit(h) => {
2300            crate::application::vcs::AsOfSpec::Commit(h)
2301        }
2302        crate::storage::query::ast::AsOfClause::Branch(b) => {
2303            crate::application::vcs::AsOfSpec::Branch(b)
2304        }
2305        crate::storage::query::ast::AsOfClause::Tag(t) => crate::application::vcs::AsOfSpec::Tag(t),
2306        crate::storage::query::ast::AsOfClause::TimestampMs(ts) => {
2307            crate::application::vcs::AsOfSpec::TimestampMs(ts)
2308        }
2309        crate::storage::query::ast::AsOfClause::Snapshot(x) => {
2310            crate::application::vcs::AsOfSpec::Snapshot(x)
2311        }
2312    };
2313    Some((spec, table_name))
2314}
2315
2316pub(super) fn query_has_volatile_builtin(sql: &str) -> bool {
2317    // Lowercase the bytes up to the first null/newline into a small
2318    // stack buffer for cheap contains() checks. Most SQL fits in the
2319    // buffer; longer queries fall back to owned lowercase.
2320    const VOLATILE_TOKENS: &[&str] = &[
2321        "pg_advisory_lock",
2322        "pg_try_advisory_lock",
2323        "pg_advisory_unlock",
2324        "random()",
2325        // `$config.<path>` / `$secret.<path>` resolve mutable runtime config /
2326        // vault state at execution time (#1370). A cached result would serve a
2327        // stale value after a later `SET CONFIG` / `SET SECRET`, so treat any
2328        // query referencing them as volatile (never result-cache it).
2329        "$config",
2330        "$secret",
2331        "$kv",
2332        // NOW() / CURRENT_TIMESTAMP / CURRENT_DATE intentionally
2333        // omitted for now — they ARE volatile but today's tests rely
2334        // on caching them. Revisit once a tighter volatility story
2335        // lands.
2336    ];
2337    let lowered = sql.to_ascii_lowercase();
2338    VOLATILE_TOKENS.iter().any(|t| lowered.contains(t))
2339}
2340
2341pub(super) fn query_is_ask_statement(sql: &str) -> bool {
2342    let trimmed = sql.trim_start();
2343    let head_end = trimmed
2344        .find(|c: char| c.is_whitespace() || c == '(' || c == ';')
2345        .unwrap_or(trimmed.len());
2346    trimmed[..head_end].eq_ignore_ascii_case("ASK")
2347}
2348
2349/// Pick the `(global_mode, collection_mode)` pair for an expression,
2350/// or `None` for variants that opt out of intent-locking entirely
2351/// (admin statements like `SHOW CONFIG`, transaction control, tenant
2352/// toggles).
2353///
2354/// Phase-1 contract:
2355/// - Reads  — `(IX-compatible) (Global, IS) → (Collection, IS)`
2356/// - Writes — `(IX-compatible) (Global, IX) → (Collection, IX)`
2357/// - DDL    — `(strong)        (Global, IX) → (Collection, X)`
2358pub(super) fn intent_lock_modes_for(
2359    expr: &QueryExpr,
2360) -> Option<(
2361    crate::storage::transaction::lock::LockMode,
2362    crate::storage::transaction::lock::LockMode,
2363)> {
2364    use crate::storage::transaction::lock::LockMode::{Exclusive, IntentExclusive, IntentShared};
2365
2366    match expr {
2367        // Reads — IS / IS.
2368        QueryExpr::Table(_)
2369        | QueryExpr::Join(_)
2370        | QueryExpr::Vector(_)
2371        | QueryExpr::Hybrid(_)
2372        | QueryExpr::Graph(_)
2373        | QueryExpr::Path(_)
2374        | QueryExpr::Ask(_)
2375        | QueryExpr::SearchCommand(_)
2376        | QueryExpr::GraphCommand(_)
2377        | QueryExpr::RankOf(_)
2378        | QueryExpr::ApproxRankOf(_)
2379        | QueryExpr::RankRange(_)
2380        | QueryExpr::QueueSelect(_) => Some((IntentShared, IntentShared)),
2381
2382        // Writes — IX / IX. Non-tabular mutations (vector insert,
2383        // graph node insert, queue push, timeseries point insert)
2384        // don't carry their own dispatch arm here; they ride through
2385        // the Insert variant or a command variant covered by the
2386        // read-side arm above. P1.T4 expands only the TableQuery-ish
2387        // writes; non-tabular kinds inherit when their DML variants
2388        // land in later phases.
2389        QueryExpr::Insert(_)
2390        | QueryExpr::Update(_)
2391        | QueryExpr::Delete(_)
2392        | QueryExpr::QueueCommand(QueueCommand::Move { .. }) => {
2393            Some((IntentExclusive, IntentExclusive))
2394        }
2395        QueryExpr::QueueCommand(_) => Some((IntentShared, IntentShared)),
2396
2397        // DDL — IX / X. A DDL against collection `c` blocks all
2398        // other writers + readers on `c` but leaves other collections
2399        // running (because Global stays IX, not X).
2400        QueryExpr::CreateTable(_)
2401        | QueryExpr::CreateCollection(_)
2402        | QueryExpr::CreateVector(_)
2403        | QueryExpr::DropTable(_)
2404        | QueryExpr::DropGraph(_)
2405        | QueryExpr::DropVector(_)
2406        | QueryExpr::DropDocument(_)
2407        | QueryExpr::DropKv(_)
2408        | QueryExpr::DropCollection(_)
2409        | QueryExpr::Truncate(_)
2410        | QueryExpr::AlterTable(_)
2411        | QueryExpr::CreateIndex(_)
2412        | QueryExpr::DropIndex(_)
2413        | QueryExpr::CreateTimeSeries(_)
2414        | QueryExpr::CreateMetric(_)
2415        | QueryExpr::AlterMetric(_)
2416        | QueryExpr::CreateSlo(_)
2417        | QueryExpr::DropTimeSeries(_)
2418        | QueryExpr::CreateQueue(_)
2419        | QueryExpr::AlterQueue(_)
2420        | QueryExpr::DropQueue(_)
2421        | QueryExpr::CreateTree(_)
2422        | QueryExpr::DropTree(_)
2423        | QueryExpr::CreatePolicy(_)
2424        | QueryExpr::DropPolicy(_)
2425        | QueryExpr::CreateView(_)
2426        | QueryExpr::DropView(_)
2427        | QueryExpr::RefreshMaterializedView(_)
2428        | QueryExpr::CreateSchema(_)
2429        | QueryExpr::DropSchema(_)
2430        | QueryExpr::CreateSequence(_)
2431        | QueryExpr::DropSequence(_)
2432        | QueryExpr::CreateServer(_)
2433        | QueryExpr::DropServer(_)
2434        | QueryExpr::CreateForeignTable(_)
2435        | QueryExpr::DropForeignTable(_) => Some((IntentExclusive, Exclusive)),
2436
2437        // Admin / control — skip intent locks. `SET TENANT`,
2438        // `BEGIN / COMMIT / ROLLBACK`, `SET CONFIG`, `SHOW CONFIG`,
2439        // `VACUUM`, etc. don't touch collection data the same way
2440        // and the existing transaction layer already serialises the
2441        // pieces that matter.
2442        _ => None,
2443    }
2444}
2445
2446/// Best-effort collection inventory for an expression. Used to pick
2447/// `Collection(...)` resources for the intent-lock guard. Overshoots
2448/// are fine (take an extra IS, benign); undershoots leak writes past
2449/// DDL X locks, so err on the side of listing more names.
2450pub(super) fn collections_referenced(expr: &QueryExpr) -> Vec<String> {
2451    let mut out = Vec::new();
2452    walk_collections(expr, &mut out);
2453    out.sort();
2454    out.dedup();
2455    out
2456}
2457
2458fn walk_collections(expr: &QueryExpr, out: &mut Vec<String>) {
2459    match expr {
2460        QueryExpr::Table(t) => out.push(t.table.clone()),
2461        QueryExpr::Join(j) => {
2462            walk_collections(&j.left, out);
2463            walk_collections(&j.right, out);
2464        }
2465        QueryExpr::Insert(i) => out.push(i.table.clone()),
2466        QueryExpr::Update(u) => out.push(u.table.clone()),
2467        QueryExpr::Delete(d) => out.push(d.table.clone()),
2468        QueryExpr::QueueSelect(q) => out.push(q.queue.clone()),
2469
2470        // DDL — include the target collection so DDL takes
2471        // `(Collection, X)` and blocks concurrent readers / writers
2472        // on the same collection. Other collections stay live
2473        // because Global is still IX.
2474        QueryExpr::CreateTable(q) => out.push(q.name.clone()),
2475        QueryExpr::CreateCollection(q) => out.push(q.name.clone()),
2476        QueryExpr::CreateVector(q) => out.push(q.name.clone()),
2477        QueryExpr::DropTable(q) => out.push(q.name.clone()),
2478        QueryExpr::DropGraph(q) => out.push(q.name.clone()),
2479        QueryExpr::DropVector(q) => out.push(q.name.clone()),
2480        QueryExpr::DropDocument(q) => out.push(q.name.clone()),
2481        QueryExpr::DropKv(q) => out.push(q.name.clone()),
2482        QueryExpr::DropCollection(q) => out.push(q.name.clone()),
2483        QueryExpr::Truncate(q) => out.push(q.name.clone()),
2484        QueryExpr::AlterTable(q) => out.push(q.name.clone()),
2485        QueryExpr::CreateIndex(q) => out.push(q.table.clone()),
2486        QueryExpr::DropIndex(q) => out.push(q.table.clone()),
2487        QueryExpr::CreateTimeSeries(q) => out.push(q.name.clone()),
2488        QueryExpr::CreateMetric(q) => out.push(q.path.clone()),
2489        QueryExpr::AlterMetric(q) => out.push(q.path.clone()),
2490        QueryExpr::CreateSlo(q) => out.push(q.path.clone()),
2491        QueryExpr::DropTimeSeries(q) => out.push(q.name.clone()),
2492        QueryExpr::CreateQueue(q) => out.push(q.name.clone()),
2493        QueryExpr::AlterQueue(q) => out.push(q.name.clone()),
2494        QueryExpr::DropQueue(q) => out.push(q.name.clone()),
2495        QueryExpr::QueueCommand(QueueCommand::Move {
2496            source,
2497            destination,
2498            ..
2499        }) => {
2500            out.push(source.clone());
2501            out.push(destination.clone());
2502        }
2503        QueryExpr::CreatePolicy(q) => out.push(q.table.clone()),
2504        QueryExpr::CreateView(q) => out.push(q.name.clone()),
2505        QueryExpr::DropView(q) => out.push(q.name.clone()),
2506        QueryExpr::RefreshMaterializedView(q) => out.push(q.name.clone()),
2507
2508        // Vector / Hybrid / Graph / Path / commands reference
2509        // collections through fields whose shape varies; without a
2510        // uniform accessor we fall back to the global lock only —
2511        // benign because every runtime path still holds the global
2512        // mode.
2513        _ => {}
2514    }
2515}
2516
2517impl RedDBRuntime {
2518    pub fn in_memory() -> RedDBResult<Self> {
2519        Self::with_options(RedDBOptions::in_memory())
2520    }
2521
2522    pub fn flush(&self) -> RedDBResult<()> {
2523        self.inner
2524            .db
2525            .flush()
2526            .map_err(|err| RedDBError::Internal(err.to_string()))
2527    }
2528
2529    /// Handle to the intent-lock manager for tests + introspection.
2530    /// Production code acquires via `LockerGuard::new(rt.lock_manager())`
2531    /// rather than touching the manager directly.
2532    pub fn lock_manager(&self) -> std::sync::Arc<crate::storage::transaction::lock::LockManager> {
2533        self.inner.lock_manager.clone()
2534    }
2535
2536    /// Process-local governance registry for managed policy/config guardrails.
2537    pub fn config_registry(&self) -> std::sync::Arc<crate::auth::registry::ConfigRegistry> {
2538        self.inner.config_registry.clone()
2539    }
2540
2541    pub fn query_audit(&self) -> std::sync::Arc<crate::runtime::query_audit::QueryAuditStream> {
2542        self.inner.query_audit.clone()
2543    }
2544
2545    pub fn control_events_require_persistence(&self) -> bool {
2546        self.inner.control_event_config.require_persistence()
2547    }
2548
2549    pub fn control_event_config(&self) -> crate::runtime::control_events::ControlEventConfig {
2550        self.inner.control_event_config
2551    }
2552
2553    pub fn control_event_ledger(
2554        &self,
2555    ) -> Arc<dyn crate::runtime::control_events::ControlEventLedger> {
2556        self.inner.control_event_ledger.read().clone()
2557    }
2558
2559    #[doc(hidden)]
2560    pub fn replace_control_event_ledger_for_tests(
2561        &self,
2562        ledger: Arc<dyn crate::runtime::control_events::ControlEventLedger>,
2563    ) {
2564        *self.inner.control_event_ledger.write() = ledger;
2565    }
2566
2567    #[inline(never)]
2568    pub fn with_options(options: RedDBOptions) -> RedDBResult<Self> {
2569        Self::with_pool(options, ConnectionPoolConfig::default())
2570    }
2571
2572    pub fn with_pool(
2573        options: RedDBOptions,
2574        pool_config: ConnectionPoolConfig,
2575    ) -> RedDBResult<Self> {
2576        // PLAN.md Phase 9.1 — capture wall-clock before storage
2577        // open so the cold-start phase markers can be backfilled
2578        // once Lifecycle is constructed below. Storage open
2579        // encapsulates auto-restore + WAL replay; we treat the
2580        // whole window as one combined "restore" + "wal_replay"
2581        // phase split at the same boundary because the storage
2582        // layer doesn't yet emit a finer signal.
2583        let boot_open_start_ms = std::time::SystemTime::now()
2584            .duration_since(std::time::UNIX_EPOCH)
2585            .map(|d| d.as_millis() as u64)
2586            .unwrap_or(0);
2587        let embedded_single_file = options.storage_profile.deploy_profile
2588            == crate::storage::DeployProfile::Embedded
2589            && options.storage_profile.packaging == crate::storage::StoragePackaging::SingleFile;
2590        let db = Arc::new(
2591            RedDB::open_with_options(&options)
2592                .map_err(|err| RedDBError::Internal(err.to_string()))?,
2593        );
2594        let result_blob_cache_config = if embedded_single_file {
2595            crate::storage::cache::BlobCacheConfig::default()
2596        } else {
2597            crate::storage::cache::BlobCacheConfig::default().with_l2_path(
2598                reddb_file::layout::result_cache_l2_path(
2599                    &options.resolved_path(reddb_file::default_database_path()),
2600                ),
2601            )
2602        };
2603        let result_blob_cache =
2604            crate::storage::cache::BlobCache::open_with_l2(result_blob_cache_config).map_err(
2605                |err| RedDBError::Internal(format!("open result Blob Cache L2 failed: {err:?}")),
2606            )?;
2607        let storage_ready_ms = std::time::SystemTime::now()
2608            .duration_since(std::time::UNIX_EPOCH)
2609            .map(|d| d.as_millis() as u64)
2610            .unwrap_or(0);
2611
2612        let runtime = Self {
2613            inner: Arc::new(RuntimeInner {
2614                db: db.clone(),
2615                layout: PhysicalLayout::from_options(&options),
2616                embedded_single_file,
2617                indices: IndexCatalog::register_default_vector_graph(
2618                    options.has_capability(crate::api::Capability::Table),
2619                    options.has_capability(crate::api::Capability::Graph),
2620                ),
2621                pool_config,
2622                pool: Mutex::new(PoolState::default()),
2623                started_at_unix_ms: SystemTime::now()
2624                    .duration_since(UNIX_EPOCH)
2625                    .unwrap_or_default()
2626                    .as_millis(),
2627                probabilistic: super::probabilistic_store::ProbabilisticStore::new(),
2628                index_store: super::index_store::IndexStore::new(),
2629                cdc: crate::replication::cdc::CdcBuffer::new(100_000),
2630                backup_scheduler: crate::replication::scheduler::BackupScheduler::new(3600),
2631                query_cache: parking_lot::RwLock::new(
2632                    crate::storage::query::planner::cache::PlanCache::new(1000),
2633                ),
2634                result_cache: parking_lot::RwLock::new((
2635                    HashMap::new(),
2636                    std::collections::VecDeque::new(),
2637                )),
2638                result_blob_cache,
2639                result_blob_entries: parking_lot::RwLock::new((
2640                    HashMap::new(),
2641                    std::collections::VecDeque::new(),
2642                )),
2643                ask_answer_cache_entries: parking_lot::RwLock::new((
2644                    HashSet::new(),
2645                    std::collections::VecDeque::new(),
2646                )),
2647                result_cache_shadow_divergences: std::sync::atomic::AtomicU64::new(0),
2648                result_cache_hits: std::sync::atomic::AtomicU64::new(0),
2649                result_cache_misses: std::sync::atomic::AtomicU64::new(0),
2650                result_cache_evictions: std::sync::atomic::AtomicU64::new(0),
2651                ask_daily_spend: parking_lot::RwLock::new(HashMap::new()),
2652                queue_message_locks: parking_lot::RwLock::new(HashMap::new()),
2653                rmw_locks: RmwLockTable::new(),
2654                planner_dirty_tables: parking_lot::RwLock::new(HashSet::new()),
2655                ec_registry: Arc::new(crate::ec::config::EcRegistry::new()),
2656                config_registry: Arc::new(crate::auth::registry::ConfigRegistry::new()),
2657                ec_worker: crate::ec::worker::EcWorker::new(),
2658                auth_store: parking_lot::RwLock::new(None),
2659                oauth_validator: parking_lot::RwLock::new(None),
2660                browser_token_authority: parking_lot::RwLock::new(None),
2661                views: parking_lot::RwLock::new(HashMap::new()),
2662                materialized_views: parking_lot::RwLock::new(
2663                    crate::storage::cache::result::MaterializedViewCache::new(),
2664                ),
2665                retention_sweeper: parking_lot::RwLock::new(
2666                    crate::runtime::retention_sweeper::RetentionSweeperState::new(),
2667                ),
2668                snapshot_manager: Arc::new(
2669                    crate::storage::transaction::snapshot::SnapshotManager::new(),
2670                ),
2671                tx_contexts: parking_lot::RwLock::new(HashMap::new()),
2672                tx_local_tenants: parking_lot::RwLock::new(HashMap::new()),
2673                env_config_overrides: crate::runtime::config_overlay::collect_env_overrides(),
2674                lock_manager: Arc::new({
2675                    // Sourced from the matrix: Tier B key
2676                    // `concurrency.locking.deadlock_timeout_ms`
2677                    // (default 5000). Env var wins at boot so
2678                    // operators can tune without touching red_config.
2679                    let env = crate::runtime::config_overlay::collect_env_overrides();
2680                    let timeout_ms = env
2681                        .get("concurrency.locking.deadlock_timeout_ms")
2682                        .and_then(|raw| raw.parse::<u64>().ok())
2683                        .unwrap_or_else(|| {
2684                            match crate::runtime::config_matrix::default_for(
2685                                "concurrency.locking.deadlock_timeout_ms",
2686                            ) {
2687                                Some(crate::serde_json::Value::Number(n)) => n as u64,
2688                                _ => 5000,
2689                            }
2690                        });
2691                    let cfg = crate::storage::transaction::lock::LockConfig {
2692                        default_timeout: std::time::Duration::from_millis(timeout_ms),
2693                        ..Default::default()
2694                    };
2695                    crate::storage::transaction::lock::LockManager::new(cfg)
2696                }),
2697                rls_policies: parking_lot::RwLock::new(HashMap::new()),
2698                rls_enabled_tables: parking_lot::RwLock::new(HashSet::new()),
2699                foreign_tables: Arc::new(crate::storage::fdw::ForeignTableRegistry::with_builtins()),
2700                pending_tombstones: parking_lot::RwLock::new(HashMap::new()),
2701                pending_versioned_updates: parking_lot::RwLock::new(HashMap::new()),
2702                pending_kv_watch_events: parking_lot::RwLock::new(HashMap::new()),
2703                pending_store_wal_actions: parking_lot::RwLock::new(HashMap::new()),
2704                pending_claim_locks: parking_lot::RwLock::new(HashMap::new()),
2705                queue_wait_registry: std::sync::Arc::new(
2706                    crate::runtime::queue_wait_registry::QueueWaitRegistry::new(),
2707                ),
2708                pending_queue_wakes: parking_lot::RwLock::new(HashMap::new()),
2709                tenant_tables: parking_lot::RwLock::new(HashMap::new()),
2710                ddl_epoch: std::sync::atomic::AtomicU64::new(0),
2711                write_gate: Arc::new(crate::runtime::write_gate::WriteGate::from_options(
2712                    &options,
2713                )),
2714                lifecycle: crate::runtime::lifecycle::Lifecycle::new(),
2715                resource_limits: crate::runtime::resource_limits::ResourceLimits::from_env(),
2716                audit_log: {
2717                    // Default audit-log path for the in-memory case
2718                    // sits in the system temp dir; persistent runs
2719                    // place it next to the resolved data file.
2720                    //
2721                    // gh-471 iter 2: route through the resolved
2722                    // `LogDestination`. Performance/Max tiers emit a
2723                    // file-backed log destination under the file-owned
2724                    // support-directory logs tier;
2725                    // lower tiers / ephemeral runs report `Stderr`
2726                    // and we keep the legacy file-next-to-data sink.
2727                    // #1375 — single-file embedded mode keeps the data
2728                    // directory to exactly the `.rdb` artifact, so the audit
2729                    // log must NOT land as a sibling. Route it to a
2730                    // process-unique temp location even when a data path is
2731                    // set; only the non-embedded case uses the data dir.
2732                    let data_path = if embedded_single_file {
2733                        std::env::temp_dir()
2734                            .join("reddb-embedded-runtime")
2735                            .join(format!("audit-{}", std::process::id()))
2736                    } else {
2737                        options
2738                            .data_path
2739                            .clone()
2740                            .unwrap_or_else(|| std::env::temp_dir().join("reddb"))
2741                    };
2742                    let (audit_dest, _) = crate::api::tier_wiring::current_log_destinations();
2743                    if !matches!(audit_dest, crate::storage::layout::LogDestination::File(_))
2744                        && (embedded_single_file
2745                            || options
2746                                .metadata
2747                                .contains_key(crate::api::EPHEMERAL_RUNTIME_METADATA_KEY))
2748                    {
2749                        // The Stderr/Syslog lower-tier sink resolves to a
2750                        // `for_data_path` sibling that collides across concurrent
2751                        // temp-dir runtimes — nextest's process-per-test model
2752                        // truncates one shared file, flaking audit assertions.
2753                        // Pin a unique sibling for these short-lived ephemeral /
2754                        // single-file embedded runtimes. The file-owned support-
2755                        // dir tier (`File`) is already per-data unique, so leave
2756                        // it to `for_destination` (#1375: the embedded audit then
2757                        // still never lands a sibling next to the `.rdb`).
2758                        let audit_path = reddb_file::layout::sibling_path(
2759                            &data_path,
2760                            &reddb_file::layout::sidecar_file_name(&data_path, "audit.log"),
2761                        );
2762                        Arc::new(crate::runtime::audit_log::AuditLogger::with_path(
2763                            audit_path,
2764                        ))
2765                    } else {
2766                        Arc::new(crate::runtime::audit_log::AuditLogger::for_destination(
2767                            &audit_dest,
2768                            &data_path,
2769                        ))
2770                    }
2771                },
2772                control_event_ledger: parking_lot::RwLock::new(Arc::new(
2773                    crate::runtime::control_events::RuntimeLedger::new(db.store()),
2774                )),
2775                control_event_config: options.control_events,
2776                query_audit: Arc::new(crate::runtime::query_audit::QueryAuditStream::new(
2777                    db.store(),
2778                    options.query_audit.clone(),
2779                )),
2780                lease_lifecycle: std::sync::OnceLock::new(),
2781                replica_apply_metrics: std::sync::Arc::new(
2782                    crate::replication::logical::ReplicaApplyMetrics::default(),
2783                ),
2784                replica_link_metrics: std::sync::Arc::new(
2785                    crate::replication::reconnect::ReplicaLinkMetrics::default(),
2786                ),
2787                quota_bucket: crate::runtime::quota_bucket::QuotaBucket::from_env(),
2788                schema_vocabulary: parking_lot::RwLock::new(
2789                    crate::runtime::schema_vocabulary::SchemaVocabulary::new(),
2790                ),
2791                slow_query_logger: {
2792                    // Issue #205 — slow-query sink lives in the same
2793                    // directory the audit log uses, so backup/restore
2794                    // ships them together. Threshold + sample-pct
2795                    // default conservatively (1 s, 100% sampling) so
2796                    // emitted lines are rare and complete. Operators
2797                    // tune via env / config matrix in a follow-up.
2798                    //
2799                    // gh-471 iter 2: same routing as the audit log —
2800                    // `LogDestination::File(...)` for Performance/Max
2801                    // lands under the file-owned support-directory logs tier;
2802                    // lower tiers fall back to `red-slow.log` in the
2803                    // data directory.
2804                    // #1375 — see the audit-log note above: single-file mode
2805                    // never writes the slow-query log as a sibling of the
2806                    // `.rdb`. Route to a process-unique temp dir when embedded,
2807                    // regardless of the data path.
2808                    let fallback_dir = if embedded_single_file {
2809                        std::env::temp_dir()
2810                            .join("reddb-embedded-runtime")
2811                            .join(format!("slow-{}", std::process::id()))
2812                    } else {
2813                        options
2814                            .data_path
2815                            .as_ref()
2816                            .and_then(|p| p.parent().map(std::path::PathBuf::from))
2817                            .unwrap_or_else(|| std::env::temp_dir().join("reddb"))
2818                    };
2819                    let threshold_ms = std::env::var("RED_SLOW_QUERY_THRESHOLD_MS")
2820                        .ok()
2821                        .and_then(|s| s.parse::<u64>().ok())
2822                        .unwrap_or(1000);
2823                    let sample_pct = std::env::var("RED_SLOW_QUERY_SAMPLE_PCT")
2824                        .ok()
2825                        .and_then(|s| s.parse::<u8>().ok())
2826                        .unwrap_or(100);
2827                    let (_, slow_dest) = crate::api::tier_wiring::current_log_destinations();
2828                    crate::telemetry::slow_query_logger::SlowQueryLogger::for_destination(
2829                        &slow_dest,
2830                        &fallback_dir,
2831                        threshold_ms,
2832                        sample_pct,
2833                    )
2834                },
2835                slow_query_store: crate::telemetry::slow_query_store::SlowQueryStore::new(
2836                    crate::telemetry::slow_query_store::DEFAULT_CAP,
2837                ),
2838                kv_stats: crate::runtime::KvStatsCounters::default(),
2839                metrics_ingest_stats: crate::runtime::MetricsIngestCounters::default(),
2840                metrics_tenant_activity_stats:
2841                    crate::runtime::MetricsTenantActivityCounters::default(),
2842                claim_telemetry: Arc::new(
2843                    crate::runtime::claim_telemetry::ClaimTelemetryCounters::default(),
2844                ),
2845                queue_telemetry: Arc::new(
2846                    crate::runtime::queue_telemetry::QueueTelemetryCounters::default(),
2847                ),
2848                query_latency_telemetry: Arc::new(
2849                    crate::runtime::query_latency_telemetry::QueryLatencyTelemetry::default(),
2850                ),
2851                occupancy_sampler: Arc::new(
2852                    crate::runtime::occupancy_sampler::OccupancySampler::new(),
2853                ),
2854                node_load_telemetry: Arc::new(
2855                    crate::runtime::node_load_telemetry::NodeLoadTelemetry::default(),
2856                ),
2857                queue_presence: Arc::new(
2858                    crate::storage::queue::presence::ConsumerPresenceRegistry::new(),
2859                ),
2860                vector_introspection: Arc::new(
2861                    crate::storage::vector::introspection::VectorIntrospectionRegistry::new(),
2862                ),
2863                kv_tag_index: crate::runtime::KvTagIndex::default(),
2864                chain_tip_cache: parking_lot::Mutex::new(HashMap::new()),
2865                chain_integrity_broken: parking_lot::Mutex::new(HashMap::new()),
2866                integrity_tombstones: parking_lot::Mutex::new(Vec::new()),
2867                integrity_tombstones_state: std::sync::atomic::AtomicU8::new(0),
2868            }),
2869        };
2870
2871        // Issue #205 — install the process-wide OperatorEvent sink so
2872        // emit sites buried in storage / replication / signal handlers
2873        // can record without threading an `&AuditLogger` through every
2874        // call stack. First registration wins; subsequent in-memory
2875        // runtimes (test harnesses) fall through to tracing+eprintln.
2876        crate::telemetry::operator_event::install_global_audit_sink(Arc::clone(
2877            &runtime.inner.audit_log,
2878        ));
2879
2880        // Issue #1238 — wire the slow-query telemetry substrate (ADR 0060).
2881        // The logger dual-writes: file sink (existing) + ring store (new).
2882        runtime
2883            .inner
2884            .slow_query_logger
2885            .attach_store(Arc::clone(&runtime.inner.slow_query_store));
2886
2887        // PLAN.md Phase 9.1 — backfill cold-start phase markers
2888        // from the wall-clock captured before storage open. The
2889        // entire `RedDB::open_with_options` call covers both
2890        // auto-restore (when configured) and WAL replay. We
2891        // record both phases against the same boundary today;
2892        // a follow-up will split them once the storage layer
2893        // surfaces a finer-grained event.
2894        runtime
2895            .inner
2896            .lifecycle
2897            .set_restore_started_at_ms(boot_open_start_ms);
2898        runtime
2899            .inner
2900            .lifecycle
2901            .set_restore_ready_at_ms(storage_ready_ms);
2902        runtime
2903            .inner
2904            .lifecycle
2905            .set_wal_replay_started_at_ms(boot_open_start_ms);
2906        runtime
2907            .inner
2908            .lifecycle
2909            .set_wal_replay_ready_at_ms(storage_ready_ms);
2910
2911        let restored_cdc_lsn = runtime
2912            .inner
2913            .db
2914            .replication
2915            .as_ref()
2916            .map(|repl| {
2917                repl.logical_wal_spool
2918                    .as_ref()
2919                    .map(|spool| spool.current_lsn())
2920                    .unwrap_or(0)
2921            })
2922            .unwrap_or(0)
2923            .max(runtime.config_u64("red.config.timeline.last_archived_lsn", 0));
2924        runtime.inner.cdc.set_current_lsn(restored_cdc_lsn);
2925        runtime.rehydrate_snapshot_xid_floor();
2926        runtime
2927            .bootstrap_system_keyed_collections()
2928            .map_err(|err| RedDBError::Internal(format!("bootstrap system collections: {err}")))?;
2929        runtime.rehydrate_declared_column_schemas();
2930        runtime.rehydrate_runtime_index_registry()?;
2931        runtime
2932            .load_probabilistic_state()
2933            .map_err(|err| RedDBError::Internal(format!("load probabilistic state: {err}")))?;
2934
2935        // Phase 2.5.4: replay `tenant_tables.{table}.column` markers so
2936        // tables declared via `TENANT BY (col)` survive restart. Each
2937        // entry re-registers the auto-policy and flips RLS on again.
2938        runtime.rehydrate_tenant_tables();
2939        // Issue #593 slice 9a — replay persisted materialized-view
2940        // descriptors so `CREATE MATERIALIZED VIEW v AS …` survives a
2941        // restart. Runs after the system-keyed collections bootstrap
2942        // and before the API opens.
2943        runtime.rehydrate_materialized_view_descriptors();
2944        if let Some(repl) = &runtime.inner.db.replication {
2945            repl.wal_buffer.set_current_lsn(restored_cdc_lsn);
2946        }
2947
2948        // Save system info to red_config on boot
2949        {
2950            let sys = SystemInfo::collect();
2951            runtime.inner.db.store().set_config_tree(
2952                "red.system",
2953                &crate::serde_json::json!({
2954                    "pid": sys.pid,
2955                    "cpu_cores": sys.cpu_cores,
2956                    "total_memory_bytes": sys.total_memory_bytes,
2957                    "available_memory_bytes": sys.available_memory_bytes,
2958                    "os": sys.os,
2959                    "arch": sys.arch,
2960                    "hostname": sys.hostname,
2961                    "started_at": SystemTime::now()
2962                        .duration_since(UNIX_EPOCH)
2963                        .unwrap_or_default()
2964                        .as_millis() as u64
2965                }),
2966            );
2967
2968            // Seed defaults on first boot (only if red_config is empty or missing defaults)
2969            let store = runtime.inner.db.store();
2970            if store
2971                .get_collection("red_config")
2972                .map(|m| m.query_all(|_| true).len())
2973                .unwrap_or(0)
2974                <= 10
2975            {
2976                store.set_config_tree("red.ai", &crate::json!({
2977                    "default": crate::json!({
2978                        "provider": "openai",
2979                        "model": crate::ai::DEFAULT_OPENAI_PROMPT_MODEL
2980                    }),
2981                    "max_embedding_inputs": 256,
2982                    "max_prompt_batch": 256,
2983                    "timeout": crate::json!({ "connect_secs": 10, "read_secs": 90, "write_secs": 30 })
2984                }));
2985                store.set_config_tree(
2986                    "red.server",
2987                    &crate::json!({
2988                        "max_scan_limit": 1000,
2989                        "max_body_size": 1048576,
2990                        "read_timeout_ms": 5000,
2991                        "write_timeout_ms": 5000
2992                    }),
2993                );
2994                store.set_config_tree(
2995                    "red.storage",
2996                    &crate::json!({
2997                        "page_size": 4096,
2998                        "page_cache_capacity": 100000,
2999                        "auto_checkpoint_pages": 1000,
3000                        "snapshot_retention": 16,
3001                        "verify_checksums": true,
3002                        "segment": crate::json!({
3003                            "max_entities": 100000,
3004                            "max_bytes": 268435456_u64,
3005                            "compression_level": 6
3006                        }),
3007                        "hnsw": crate::json!({ "m": 16, "ef_construction": 100, "ef_search": 50 }),
3008                        "ivf": crate::json!({ "n_lists": 100, "n_probes": 10 }),
3009                        "bm25": crate::json!({ "k1": 1.2, "b": 0.75 })
3010                    }),
3011                );
3012                store.set_config_tree(
3013                    "red.search",
3014                    &crate::json!({
3015                        "rag": crate::json!({
3016                            "max_chunks_per_source": 10,
3017                            "max_total_chunks": 25,
3018                            "similarity_threshold": 0.8,
3019                            "graph_depth": 2,
3020                            "min_relevance": 0.3
3021                        }),
3022                        "fusion": crate::json!({
3023                            "vector_weight": 0.5,
3024                            "graph_weight": 0.3,
3025                            "table_weight": 0.2,
3026                            "dedup_threshold": 0.85
3027                        })
3028                    }),
3029                );
3030                store.set_config_tree(
3031                    "red.auth",
3032                    &crate::json!({
3033                        "enabled": false,
3034                        "session_ttl_secs": 3600,
3035                        "require_auth": false
3036                    }),
3037                );
3038                store.set_config_tree(
3039                    "red.query",
3040                    &crate::json!({
3041                        "connection_pool": crate::json!({ "max_connections": 64, "max_idle": 16 }),
3042                        "max_recursion_depth": 1000
3043                    }),
3044                );
3045                store.set_config_tree(
3046                    "red.indexes",
3047                    &crate::json!({
3048                        "auto_select": true,
3049                        "bloom_filter": crate::json!({
3050                            "enabled": true,
3051                            "false_positive_rate": 0.01,
3052                            "prune_on_scan": true
3053                        }),
3054                        "hash": crate::json!({ "enabled": true }),
3055                        "bitmap": crate::json!({ "enabled": true, "max_cardinality": 1000 }),
3056                        "spatial": crate::json!({ "enabled": true })
3057                    }),
3058                );
3059                store.set_config_tree(
3060                    "red.memtable",
3061                    &crate::json!({
3062                        "enabled": true,
3063                        "max_bytes": 67108864_u64,
3064                        "flush_threshold": 0.75
3065                    }),
3066                );
3067                store.set_config_tree(
3068                    "red.probabilistic",
3069                    &crate::json!({
3070                        "hll_registers": 16384,
3071                        "sketch_default_width": 1000,
3072                        "sketch_default_depth": 5,
3073                        "filter_default_capacity": 100000
3074                    }),
3075                );
3076                store.set_config_tree(
3077                    "red.timeseries",
3078                    &crate::json!({
3079                        "default_chunk_size": 1024,
3080                        "compression": crate::json!({
3081                            "timestamps": "delta_of_delta",
3082                            "values": "gorilla_xor"
3083                        }),
3084                        "default_retention_days": 0
3085                    }),
3086                );
3087                store.set_config_tree(
3088                    "red.queue",
3089                    &crate::json!({
3090                        "default_max_size": 0,
3091                        "default_max_attempts": 3,
3092                        "visibility_timeout_ms": 30000,
3093                        "consumer_idle_timeout_ms": 60000
3094                    }),
3095                );
3096                store.set_config_tree(
3097                    "red.backup",
3098                    &crate::json!({
3099                        "enabled": false,
3100                        "interval_secs": 3600,
3101                        "retention_count": 24,
3102                        "upload": false,
3103                        "backend": "local"
3104                    }),
3105                );
3106                store.set_config_tree(
3107                    "red.wal",
3108                    &crate::json!({
3109                        "archive": crate::json!({
3110                            "enabled": false,
3111                            "retention_hours": 168,
3112                            "prefix": reddb_file::backup_wal_prefix("")
3113                        })
3114                    }),
3115                );
3116                store.set_config_tree(
3117                    "red.cdc",
3118                    &crate::json!({
3119                        "enabled": true,
3120                        "buffer_size": 100000
3121                    }),
3122                );
3123                store.set_config_tree(
3124                    "red.config.secret",
3125                    &crate::json!({
3126                        "auto_encrypt": true,
3127                        "auto_decrypt": true
3128                    }),
3129                );
3130            }
3131
3132            // Perf-parity config matrix: heal the Tier A (critical)
3133            // keys unconditionally on every boot. Idempotent — only
3134            // writes the default when the key is missing. Keeps
3135            // `SHOW CONFIG` showing every guarantee the operator has
3136            // (durability.mode, concurrency.locking.enabled, …) even
3137            // on long-running datadirs that predate the matrix.
3138            crate::runtime::config_matrix::heal_critical_keys(store.as_ref());
3139            seed_storage_deploy_config(store.as_ref(), options.storage_profile);
3140
3141            // Phase 5 — Lehman-Yao runtime flag. Read the Tier A
3142            // `storage.btree.lehman_yao` value from the matrix (env
3143            // > file > red_config > default) and publish it to the
3144            // storage layer's atomic so the B-tree read / split
3145            // paths can branch without re-reading the config on
3146            // every hot-path call.
3147            let lehman_yao = runtime.config_bool("storage.btree.lehman_yao", true);
3148            crate::storage::engine::btree::lehman_yao::set_enabled(lehman_yao);
3149            if lehman_yao {
3150                tracing::info!(
3151                    "storage.btree.lehman_yao=true — lock-free concurrent descent enabled"
3152                );
3153            }
3154
3155            // Config file overlay — mounted `/etc/reddb/config.json`
3156            // (override path via REDDB_CONFIG_FILE). Writes keys with
3157            // write-if-absent semantics so a later user `SET CONFIG`
3158            // always wins. Missing file = silent no-op.
3159            let overlay_path = crate::runtime::config_overlay::config_file_path();
3160            let _ =
3161                crate::runtime::config_overlay::apply_config_file(store.as_ref(), &overlay_path);
3162        }
3163
3164        // VCS ("Git for Data") — create the `red_*` metadata
3165        // collections on first boot. Idempotent: `get_or_create_collection`
3166        // is a no-op if the collection already exists.
3167        {
3168            let store = runtime.inner.db.store();
3169            for name in crate::application::vcs_collections::ALL {
3170                let _ = store.get_or_create_collection(*name);
3171            }
3172            // Seed VCS config namespace with sensible defaults on first
3173            // boot, matching the pattern used by red.ai / red.storage.
3174            store.set_config_tree(
3175                crate::application::vcs_collections::CONFIG_NAMESPACE,
3176                &crate::json!({
3177                    "default_branch": "main",
3178                    "author": crate::json!({
3179                        "name": "reddb",
3180                        "email": "reddb@localhost"
3181                    }),
3182                    "protected_branches": crate::json!(["main"]),
3183                    "closure": crate::json!({
3184                        "enabled": true,
3185                        "lazy": true
3186                    }),
3187                    "merge": crate::json!({
3188                        "default_strategy": "auto",
3189                        "fast_forward": true
3190                    })
3191                }),
3192            );
3193        }
3194
3195        // Migrations — create the `red_migrations` / `red_migration_deps`
3196        // system collections on first boot. Idempotent.
3197        {
3198            let store = runtime.inner.db.store();
3199            for name in crate::application::migration_collections::ALL {
3200                let _ = store.get_or_create_collection(*name);
3201            }
3202        }
3203
3204        // Topology graph (#803) — ensure the built-in `red.topology.cluster`
3205        // graph collection (declared WITH ANALYTICS) and its metadata sidecar
3206        // exist. Idempotent and survives restarts via the WAL-backed contract.
3207        let _ = crate::application::topology_collections::ensure(&runtime);
3208
3209        // #1369 — reserve a fixed internal-id floor so the first user-inserted
3210        // entity always receives a stable, documented `rid` (FIRST_USER_ENTITY_ID),
3211        // independent of how many internal collection-descriptor / config-default
3212        // entities the boot sequence seeded above. `register_entity_id` only ever
3213        // raises the allocator, so a database that already holds user data
3214        // (counter past the floor) is untouched; a freshly-seeded database jumps
3215        // straight to the floor.
3216        runtime
3217            .inner
3218            .db
3219            .store()
3220            .register_entity_id(crate::storage::EntityId::new(
3221                crate::storage::FIRST_USER_ENTITY_ID - 1,
3222            ));
3223
3224        // Start background maintenance thread (context index refresh +
3225        // session purge). Held by a WEAK reference to `RuntimeInner`
3226        // so dropping the last `RedDBRuntime` handle actually releases
3227        // the underlying Arc<Pager> (and its file lock). Polling at
3228        // 200ms means shutdown latency is bounded; the real 60-second
3229        // work cadence is tracked independently via a `last_work`
3230        // timestamp.
3231        //
3232        // The previous version captured `rt = runtime.clone()` by
3233        // strong reference and ran an unterminated `loop`, which held
3234        // Arc<RuntimeInner> forever — reopening a persistent database
3235        // in the same process failed with "Database is locked" because
3236        // the pager could never drop. See the regression test
3237        // `finding_1_select_after_bulk_insert_persistent_reopen`.
3238        {
3239            let weak = Arc::downgrade(&runtime.inner);
3240            std::thread::Builder::new()
3241                .name("reddb-maintenance".into())
3242                .spawn(move || {
3243                    let tick = std::time::Duration::from_millis(200);
3244                    let work_interval = std::time::Duration::from_secs(60);
3245                    let mut last_work = std::time::Instant::now();
3246                    loop {
3247                        std::thread::sleep(tick);
3248                        let Some(inner) = weak.upgrade() else {
3249                            // All strong references dropped — the
3250                            // runtime is gone, exit cleanly.
3251                            break;
3252                        };
3253                        if last_work.elapsed() >= work_interval {
3254                            let _stats = inner.db.store().context_index().stats();
3255                            last_work = std::time::Instant::now();
3256                        }
3257                    }
3258                })
3259                .ok();
3260        }
3261
3262        // Start backup scheduler if enabled via red_config
3263        {
3264            let store = runtime.inner.db.store();
3265            let mut backup_enabled = false;
3266            let mut backup_interval = 3600u64;
3267
3268            if let Some(manager) = store.get_collection("red_config") {
3269                manager.for_each_entity(|entity| {
3270                    if let Some(row) = entity.data.as_row() {
3271                        let key = row.get_field("key").and_then(|v| match v {
3272                            crate::storage::schema::Value::Text(s) => Some(s.as_ref()),
3273                            _ => None,
3274                        });
3275                        let val = row.get_field("value");
3276                        if key == Some("red.config.backup.enabled") {
3277                            backup_enabled = match val {
3278                                Some(crate::storage::schema::Value::Boolean(true)) => true,
3279                                Some(crate::storage::schema::Value::Text(s)) => &**s == "true",
3280                                _ => false,
3281                            };
3282                        } else if key == Some("red.config.backup.interval_secs") {
3283                            if let Some(crate::storage::schema::Value::Integer(n)) = val {
3284                                backup_interval = *n as u64;
3285                            }
3286                        }
3287                    }
3288                    true
3289                });
3290            }
3291
3292            if backup_enabled {
3293                runtime.inner.backup_scheduler.set_interval(backup_interval);
3294                let rt = runtime.clone();
3295                runtime
3296                    .inner
3297                    .backup_scheduler
3298                    .start(move || rt.trigger_backup().map_err(|e| format!("{}", e)));
3299            }
3300        }
3301
3302        // Load EC registry from red_config and start worker
3303        {
3304            runtime
3305                .inner
3306                .ec_registry
3307                .load_from_config_store(runtime.inner.db.store().as_ref());
3308            if !runtime.inner.ec_registry.async_configs().is_empty() {
3309                runtime.inner.ec_worker.start(
3310                    Arc::clone(&runtime.inner.ec_registry),
3311                    Arc::clone(&runtime.inner.db.store()),
3312                );
3313            }
3314        }
3315
3316        if let crate::replication::ReplicationRole::Replica { primary_addr } =
3317            runtime.inner.db.options().replication.role.clone()
3318        {
3319            let rt = runtime.clone();
3320            std::thread::Builder::new()
3321                .name("reddb-replica".into())
3322                .spawn(move || rt.run_replica_loop(primary_addr))
3323                .ok();
3324        }
3325
3326        // PLAN.md Phase 1 — Lifecycle Contract. Mark Ready once every
3327        // boot stage above has completed (WAL replay, restore-from-
3328        // remote, replica-loop spawn). Health probes flip from 503 to
3329        // 200 here; shutdown begins from this state.
3330        runtime.inner.lifecycle.mark_ready();
3331
3332        // Issue #583 slice 10 — ContinuousMaterializedView scheduler.
3333        // Low-priority background ticker that drains the cache's
3334        // `claim_due_at` set every ~50ms. Holds only a Weak<RuntimeInner>
3335        // so the thread exits cleanly when the runtime drops (≤50ms
3336        // latency between drop and exit). Materialized views without
3337        // a `REFRESH EVERY` clause stay on the manual-refresh path
3338        // and are skipped by `claim_due_at`, so the loop is a no-op
3339        // when no scheduled views exist.
3340        {
3341            let weak_inner = Arc::downgrade(&runtime.inner);
3342            std::thread::Builder::new()
3343                .name("reddb-mv-scheduler".into())
3344                // Rust's default for spawned threads is 2 MiB, which is too
3345                // small for the query-execution path that refresh_due_materialized_views
3346                // runs (StatementExecutionFrame + guards per view). Match the
3347                // 8 MiB that the OS gives the main thread.
3348                .stack_size(8 * 1024 * 1024)
3349                .spawn(move || loop {
3350                    std::thread::sleep(std::time::Duration::from_millis(50));
3351                    let Some(inner) = weak_inner.upgrade() else {
3352                        break;
3353                    };
3354                    let rt = RedDBRuntime { inner };
3355                    rt.refresh_due_materialized_views();
3356                })
3357                .ok();
3358        }
3359
3360        // Issue #584 slice 12 — DeclarativeRetention background sweeper.
3361        // Low-priority ticker that physically reclaims rows whose
3362        // timestamp has fallen beyond the retention window. Holds a
3363        // `Weak<RuntimeInner>` so the thread exits within one tick of
3364        // the runtime drop (graceful shutdown leaves storage consistent
3365        // because each tick goes through the standard DELETE path —
3366        // there is no half-finished mutation state to clean up). The
3367        // tick interval is intentionally longer than the MV scheduler
3368        // (500ms) because retention is order-of-seconds at minimum.
3369        if !runtime.write_gate().is_read_only() {
3370            let weak_inner = Arc::downgrade(&runtime.inner);
3371            std::thread::Builder::new()
3372                .name("reddb-retention-sweeper".into())
3373                .spawn(move || loop {
3374                    std::thread::sleep(std::time::Duration::from_millis(500));
3375                    let Some(inner) = weak_inner.upgrade() else {
3376                        break;
3377                    };
3378                    let rt = RedDBRuntime { inner };
3379                    rt.sweep_retention_tick(
3380                        crate::runtime::retention_sweeper::DEFAULT_SWEEPER_BATCH,
3381                    );
3382                })
3383                .ok();
3384        }
3385
3386        Ok(runtime)
3387    }
3388
3389    fn rehydrate_snapshot_xid_floor(&self) {
3390        let store = self.inner.db.store();
3391        for collection in store.list_collections() {
3392            let Some(manager) = store.get_collection(&collection) else {
3393                continue;
3394            };
3395            for entity in manager.query_all(|_| true) {
3396                self.inner
3397                    .snapshot_manager
3398                    .observe_committed_xid(entity.xmin);
3399                self.inner
3400                    .snapshot_manager
3401                    .observe_committed_xid(entity.xmax);
3402            }
3403        }
3404    }
3405
3406    /// Provision an empty Table-shaped collection that backs a
3407    /// `CREATE MATERIALIZED VIEW v` (issue #594 slice 9b of #575).
3408    /// `SELECT FROM v` reads this collection directly; the rewriter is
3409    /// configured to skip materialized views so the body is no longer
3410    /// substituted. REFRESH still writes to the cache slot — wiring it
3411    /// into this backing collection is the job of slice 9c.
3412    ///
3413    /// Idempotent: re-running for the same name leaves the existing
3414    /// collection in place (mirrors `CREATE TABLE IF NOT EXISTS`
3415    /// semantics). This keeps `CREATE OR REPLACE MATERIALIZED VIEW v`
3416    /// cheap — the body change does not invalidate already-buffered
3417    /// rows. Until 9c lands the backing is always empty anyway.
3418    pub(crate) fn ensure_materialized_view_backing(&self, name: &str) -> RedDBResult<()> {
3419        let store = self.inner.db.store();
3420        let mut changed = false;
3421        if store.get_collection(name).is_none() {
3422            store.get_or_create_collection(name);
3423            changed = true;
3424        }
3425        if self.inner.db.collection_contract(name).is_none() {
3426            self.inner
3427                .db
3428                .save_collection_contract(system_keyed_collection_contract(
3429                    name,
3430                    crate::catalog::CollectionModel::Table,
3431                ))
3432                .map_err(|err| RedDBError::Internal(err.to_string()))?;
3433            changed = true;
3434        }
3435        if changed {
3436            self.inner
3437                .db
3438                .persist_metadata()
3439                .map_err(|err| RedDBError::Internal(err.to_string()))?;
3440        }
3441        Ok(())
3442    }
3443
3444    /// Inverse of [`ensure_materialized_view_backing`] — drops the
3445    /// backing collection on `DROP MATERIALIZED VIEW v`. No-op when
3446    /// the collection was never created (e.g. a `DROP MATERIALIZED
3447    /// VIEW IF EXISTS v` against an unknown name).
3448    pub(crate) fn drop_materialized_view_backing(&self, name: &str) -> RedDBResult<()> {
3449        let store = self.inner.db.store();
3450        if store.get_collection(name).is_none() {
3451            return Ok(());
3452        }
3453        store
3454            .drop_collection(name)
3455            .map_err(|err| RedDBError::Internal(err.to_string()))?;
3456        // The contract may have been dropped already (DROP TABLE path)
3457        // — ignore "not found" errors by checking presence first.
3458        if self.inner.db.collection_contract(name).is_some() {
3459            self.inner
3460                .db
3461                .remove_collection_contract(name)
3462                .map_err(|err| RedDBError::Internal(err.to_string()))?;
3463        }
3464        self.invalidate_result_cache();
3465        self.inner
3466            .db
3467            .persist_metadata()
3468            .map_err(|err| RedDBError::Internal(err.to_string()))?;
3469        Ok(())
3470    }
3471
3472    fn bootstrap_system_keyed_collections(&self) -> RedDBResult<()> {
3473        let mut changed = false;
3474        for (name, model) in [
3475            ("red.config", crate::catalog::CollectionModel::Config),
3476            ("red.vault", crate::catalog::CollectionModel::Vault),
3477            // Issue #593 — materialized-view catalog. One row per
3478            // `CREATE MATERIALIZED VIEW`; rehydrated at boot before
3479            // the API opens.
3480            (
3481                crate::runtime::continuous_materialized_view::CATALOG_COLLECTION,
3482                crate::catalog::CollectionModel::Config,
3483            ),
3484        ] {
3485            if self.inner.db.store().get_collection(name).is_none() {
3486                self.inner.db.store().get_or_create_collection(name);
3487                changed = true;
3488            }
3489            if self.inner.db.collection_contract(name).is_none() {
3490                self.inner
3491                    .db
3492                    .save_collection_contract(system_keyed_collection_contract(name, model))
3493                    .map_err(|err| RedDBError::Internal(err.to_string()))?;
3494                changed = true;
3495            }
3496        }
3497        if changed {
3498            self.inner
3499                .db
3500                .persist_metadata()
3501                .map_err(|err| RedDBError::Internal(err.to_string()))?;
3502        }
3503        Ok(())
3504    }
3505
3506    pub fn db(&self) -> Arc<RedDB> {
3507        Arc::clone(&self.inner.db)
3508    }
3509
3510    /// Direct access to the runtime's secondary-index store.
3511    /// Used by bulk-insert entry points (gRPC binary bulk, HTTP bulk,
3512    /// wire bulk) that need to push new rows through the per-index
3513    /// maintenance hook after `store.bulk_insert` returns.
3514    pub fn index_store_ref(&self) -> &super::index_store::IndexStore {
3515        &self.inner.index_store
3516    }
3517
3518    /// Apply a DDL event to the schema-vocabulary reverse index
3519    /// (issue #120). Called by DDL execution paths after the catalog
3520    /// mutation has succeeded so the index never holds entries for
3521    /// half-applied DDL.
3522    pub(crate) fn schema_vocabulary_apply(
3523        &self,
3524        event: crate::runtime::schema_vocabulary::DdlEvent,
3525    ) {
3526        self.inner.schema_vocabulary.write().on_ddl(event);
3527    }
3528
3529    /// Lookup `token` in the schema-vocabulary reverse index. Returns
3530    /// an owned `Vec<VocabHit>` because the underlying read lock
3531    /// cannot be borrowed across the call boundary; the slice from
3532    /// `SchemaVocabulary::lookup` is cloned per hit.
3533    pub fn schema_vocabulary_lookup(
3534        &self,
3535        token: &str,
3536    ) -> Vec<crate::runtime::schema_vocabulary::VocabHit> {
3537        self.inner.schema_vocabulary.read().lookup(token).to_vec()
3538    }
3539
3540    /// Inject an AuthStore into the runtime. Called by server boot
3541    /// after the vault has been bootstrapped, so that `Value::Secret`
3542    /// auto-encrypt/decrypt can reach the vault AES key.
3543    pub fn set_auth_store(&self, store: Arc<crate::auth::store::AuthStore>) {
3544        *self.inner.auth_store.write() = Some(store);
3545    }
3546
3547    /// Snapshot the current AuthStore (if any). Used by the wire listener
3548    /// to validate bearer tokens issued via HTTP `/auth/login`.
3549    pub fn auth_store(&self) -> Option<Arc<crate::auth::store::AuthStore>> {
3550        self.inner.auth_store.read().clone()
3551    }
3552
3553    /// Read a vault KV secret from the configured AuthStore, if present.
3554    pub fn vault_kv_get(&self, key: &str) -> Option<String> {
3555        self.inner
3556            .auth_store
3557            .read()
3558            .as_ref()
3559            .and_then(|store| store.vault_kv_get(key))
3560    }
3561
3562    /// Write a vault KV secret and fail if the encrypted vault write is
3563    /// unavailable or cannot be made durable.
3564    pub fn vault_kv_try_set(&self, key: String, value: String) -> RedDBResult<()> {
3565        let store = self.inner.auth_store.read().clone().ok_or_else(|| {
3566            RedDBError::Query("secret storage requires an enabled, unsealed vault".to_string())
3567        })?;
3568        store
3569            .vault_kv_try_set(key, value)
3570            .map_err(|err| RedDBError::Query(err.to_string()))
3571    }
3572
3573    /// Inject an `OAuthValidator` into the runtime. When set, HTTP and
3574    /// wire transports try OAuth JWT validation before falling back to
3575    /// the local AuthStore lookup. Pass `None` to disable.
3576    pub fn set_oauth_validator(&self, validator: Option<Arc<crate::auth::oauth::OAuthValidator>>) {
3577        *self.inner.oauth_validator.write() = validator;
3578    }
3579
3580    /// Returns a clone of the configured `OAuthValidator` Arc, if any.
3581    /// Hot path: called per HTTP request when an Authorization header
3582    /// is present, so we hand back a cheap Arc clone.
3583    pub fn oauth_validator(&self) -> Option<Arc<crate::auth::oauth::OAuthValidator>> {
3584        self.inner.oauth_validator.read().clone()
3585    }
3586
3587    /// Inject the browser-token authority (issue #936). When set, the
3588    /// RedWire WS handshake accepts the short-lived access JWT it mints
3589    /// (alongside, and tried before, the federated OAuth validator), and
3590    /// the `/auth/browser/*` HTTP endpoints can issue/rotate the pair.
3591    /// `None` leaves the browser credential flow inert.
3592    pub fn set_browser_token_authority(
3593        &self,
3594        authority: Option<Arc<crate::auth::browser_token::BrowserTokenAuthority>>,
3595    ) {
3596        *self.inner.browser_token_authority.write() = authority;
3597    }
3598
3599    /// Snapshot the browser-token authority, if wired. Read on the WS
3600    /// handshake path and by the `/auth/browser/*` handlers; a cheap Arc
3601    /// clone keeps the lock hold short.
3602    pub fn browser_token_authority(
3603        &self,
3604    ) -> Option<Arc<crate::auth::browser_token::BrowserTokenAuthority>> {
3605        self.inner.browser_token_authority.read().clone()
3606    }
3607
3608    /// Returns the vault AES key (`red.secret.aes_key`) if an auth
3609    /// store is wired and a key has been generated. Used by the
3610    /// `Value::Secret` encrypt/decrypt pipeline.
3611    pub(crate) fn secret_aes_key(&self) -> Option<[u8; 32]> {
3612        let guard = self.inner.auth_store.read();
3613        guard.as_ref().and_then(|s| s.vault_secret_key())
3614    }
3615
3616    /// Resolve a boolean flag from `red_config`. Defaults to `default`
3617    /// when the key is missing or not coercible. If the same key has
3618    /// been written multiple times (SET CONFIG appends new rows), the
3619    /// most recent entity wins. Env-var overrides
3620    /// (`REDDB_<UP_DOTTED>`) take highest precedence.
3621    pub(crate) fn config_bool(&self, key: &str, default: bool) -> bool {
3622        if let Some(raw) = self.inner.env_config_overrides.get(key) {
3623            if let Some(crate::storage::schema::Value::Boolean(b)) =
3624                crate::runtime::config_overlay::coerce_env_value(key, raw)
3625            {
3626                return b;
3627            }
3628        }
3629        let store = self.inner.db.store();
3630        let Some(manager) = store.get_collection("red_config") else {
3631            return default;
3632        };
3633        let mut result = default;
3634        let mut latest_id: u64 = 0;
3635        manager.for_each_entity(|entity| {
3636            if let Some(row) = entity.data.as_row() {
3637                let entry_key = row.get_field("key").and_then(|v| match v {
3638                    crate::storage::schema::Value::Text(s) => Some(s.as_ref()),
3639                    _ => None,
3640                });
3641                if entry_key == Some(key) {
3642                    let id = entity.id.raw();
3643                    if id >= latest_id {
3644                        latest_id = id;
3645                        result = match row.get_field("value") {
3646                            Some(crate::storage::schema::Value::Boolean(b)) => *b,
3647                            Some(crate::storage::schema::Value::Text(s)) => {
3648                                matches!(s.as_ref(), "true" | "TRUE" | "True" | "1")
3649                            }
3650                            Some(crate::storage::schema::Value::Integer(n)) => *n != 0,
3651                            _ => default,
3652                        };
3653                    }
3654                }
3655            }
3656            true
3657        });
3658        result
3659    }
3660
3661    /// Whether DOCUMENT writes should store the body as the native binary
3662    /// container (PRD-1398, ADR-0063). On by default after the production
3663    /// cutover. Reads decode the container transparently regardless of this
3664    /// flag.
3665    pub(crate) fn binary_document_body_enabled(&self) -> bool {
3666        self.config_bool("storage.binary_document_body", true)
3667    }
3668
3669    pub(crate) fn config_u64(&self, key: &str, default: u64) -> u64 {
3670        if let Some(raw) = self.inner.env_config_overrides.get(key) {
3671            if let Some(crate::storage::schema::Value::UnsignedInteger(n)) =
3672                crate::runtime::config_overlay::coerce_env_value(key, raw)
3673            {
3674                return n;
3675            }
3676        }
3677        let store = self.inner.db.store();
3678        let Some(manager) = store.get_collection("red_config") else {
3679            return default;
3680        };
3681        let mut result = default;
3682        let mut latest_id: u64 = 0;
3683        manager.for_each_entity(|entity| {
3684            if let Some(row) = entity.data.as_row() {
3685                let entry_key = row.get_field("key").and_then(|v| match v {
3686                    crate::storage::schema::Value::Text(s) => Some(s.as_ref()),
3687                    _ => None,
3688                });
3689                if entry_key == Some(key) {
3690                    let id = entity.id.raw();
3691                    if id >= latest_id {
3692                        latest_id = id;
3693                        result = match row.get_field("value") {
3694                            Some(crate::storage::schema::Value::Integer(n)) => *n as u64,
3695                            Some(crate::storage::schema::Value::UnsignedInteger(n)) => *n,
3696                            Some(crate::storage::schema::Value::Text(s)) => {
3697                                s.parse::<u64>().unwrap_or(default)
3698                            }
3699                            _ => default,
3700                        };
3701                    }
3702                }
3703            }
3704            true
3705        });
3706        result
3707    }
3708
3709    pub(crate) fn config_f64(&self, key: &str, default: f64) -> f64 {
3710        if let Some(raw) = self.inner.env_config_overrides.get(key) {
3711            if let Ok(n) = raw.parse::<f64>() {
3712                return n;
3713            }
3714        }
3715        let store = self.inner.db.store();
3716        let Some(manager) = store.get_collection("red_config") else {
3717            return default;
3718        };
3719        let mut result = default;
3720        let mut latest_id: u64 = 0;
3721        manager.for_each_entity(|entity| {
3722            if let Some(row) = entity.data.as_row() {
3723                let entry_key = row.get_field("key").and_then(|v| match v {
3724                    crate::storage::schema::Value::Text(s) => Some(s.as_ref()),
3725                    _ => None,
3726                });
3727                if entry_key == Some(key) {
3728                    let id = entity.id.raw();
3729                    if id >= latest_id {
3730                        latest_id = id;
3731                        result = match row.get_field("value") {
3732                            Some(crate::storage::schema::Value::Float(n)) => *n,
3733                            Some(crate::storage::schema::Value::Integer(n)) => *n as f64,
3734                            Some(crate::storage::schema::Value::UnsignedInteger(n)) => *n as f64,
3735                            Some(crate::storage::schema::Value::Text(s)) => {
3736                                s.parse::<f64>().unwrap_or(default)
3737                            }
3738                            _ => default,
3739                        };
3740                    }
3741                }
3742            }
3743            true
3744        });
3745        result
3746    }
3747
3748    pub(crate) fn config_string(&self, key: &str, default: &str) -> String {
3749        if let Some(raw) = self.inner.env_config_overrides.get(key) {
3750            return raw.clone();
3751        }
3752        let store = self.inner.db.store();
3753        let Some(manager) = store.get_collection("red_config") else {
3754            return default.to_string();
3755        };
3756        let mut result = default.to_string();
3757        let mut latest_id: u64 = 0;
3758        manager.for_each_entity(|entity| {
3759            if let Some(row) = entity.data.as_row() {
3760                let entry_key = row.get_field("key").and_then(|v| match v {
3761                    crate::storage::schema::Value::Text(s) => Some(s.as_ref()),
3762                    _ => None,
3763                });
3764                if entry_key == Some(key) {
3765                    let id = entity.id.raw();
3766                    if id >= latest_id {
3767                        latest_id = id;
3768                        if let Some(crate::storage::schema::Value::Text(value)) =
3769                            row.get_field("value")
3770                        {
3771                            result = value.to_string();
3772                        }
3773                    }
3774                }
3775            }
3776            true
3777        });
3778        result
3779    }
3780
3781    /// Whether `SECRET('...')` literals should be encrypted with the
3782    /// vault AES key on INSERT. Default `true`.
3783    pub(crate) fn secret_auto_encrypt(&self) -> bool {
3784        self.config_bool("red.config.secret.auto_encrypt", true)
3785    }
3786
3787    /// Whether `Value::Secret` columns should be decrypted back to
3788    /// plaintext on SELECT when the vault is unsealed. Default `true`.
3789    /// Turning this off keeps secrets masked as `***` even while the
3790    /// vault is open — useful for audit trails or read-only exports.
3791    pub(crate) fn secret_auto_decrypt(&self) -> bool {
3792        self.config_bool("red.config.secret.auto_decrypt", true)
3793    }
3794
3795    /// Walk every record in `result` and swap `Value::Secret(bytes)`
3796    /// for the decrypted plaintext when the runtime has the vault
3797    /// AES key AND `red.config.secret.auto_decrypt = true`. If the
3798    /// key is missing, the vault is sealed, or auto_decrypt is off,
3799    /// secrets are left as `Value::Secret` which every formatter
3800    /// (Display, JSON) already masks as `***`.
3801    pub(crate) fn apply_secret_decryption(&self, result: &mut RuntimeQueryResult) {
3802        if !self.secret_auto_decrypt() {
3803            return;
3804        }
3805        let Some(key) = self.secret_aes_key() else {
3806            return;
3807        };
3808        for record in result.result.records.iter_mut() {
3809            for value in record.values_mut() {
3810                if let Value::Secret(ref bytes) = value {
3811                    if let Some(plain) =
3812                        super::impl_dml::decrypt_secret_payload(&key, bytes.as_slice())
3813                    {
3814                        if let Ok(text) = String::from_utf8(plain) {
3815                            *value = Value::text(text);
3816                        }
3817                    }
3818                }
3819            }
3820        }
3821    }
3822
3823    /// Emit a CDC change event and replicate to WAL buffer.
3824    /// Create a `MutationEngine` bound to this runtime.
3825    ///
3826    /// The engine is cheap to construct (no allocation) and should be
3827    /// dropped after `apply` returns. Use this from application-layer
3828    /// `create_row` / `create_rows_batch` instead of calling
3829    /// `bulk_insert` + `index_entity_insert` + `cdc_emit` separately.
3830    pub(crate) fn mutation_engine(&self) -> crate::runtime::mutation::MutationEngine<'_> {
3831        crate::runtime::mutation::MutationEngine::new(self)
3832    }
3833
3834    /// Public-mutation gate snapshot (PLAN.md W1).
3835    ///
3836    /// Surfaces that accept untrusted client requests (SQL DML/DDL,
3837    /// gRPC mutating RPCs, HTTP/native wire mutations, admin
3838    /// maintenance, serverless lifecycle) call `check_write` before
3839    /// dispatching to storage. Returns `RedDBError::ReadOnly` on any
3840    /// instance running as a replica or with `options.read_only =
3841    /// true`. The replica internal logical-WAL apply path reaches into
3842    /// the store directly and never calls this method, so legitimate
3843    /// replica catch-up still works.
3844    pub fn check_write(&self, kind: crate::runtime::write_gate::WriteKind) -> RedDBResult<()> {
3845        self.inner.write_gate.check(kind)
3846    }
3847
3848    /// Read-only handle to the gate, useful for transports that want
3849    /// to surface the policy in health/status output without taking on
3850    /// a dependency on the concrete enum.
3851    pub fn write_gate(&self) -> &crate::runtime::write_gate::WriteGate {
3852        &self.inner.write_gate
3853    }
3854
3855    /// Process lifecycle handle (PLAN.md Phase 1). Health probes,
3856    /// admin/shutdown, and signal handlers consult this single
3857    /// state machine.
3858    pub fn lifecycle(&self) -> &crate::runtime::lifecycle::Lifecycle {
3859        &self.inner.lifecycle
3860    }
3861
3862    /// Operator-imposed resource limits (PLAN.md Phase 4.1).
3863    pub fn resource_limits(&self) -> &crate::runtime::resource_limits::ResourceLimits {
3864        &self.inner.resource_limits
3865    }
3866
3867    /// Append-only audit log for admin mutations (PLAN.md Phase 6.5).
3868    pub fn audit_log(&self) -> &crate::runtime::audit_log::AuditLogger {
3869        &self.inner.audit_log
3870    }
3871
3872    /// Shared `Arc` to the audit logger — used by collaborators (the
3873    /// lease lifecycle, future request-context plumbing) that need to
3874    /// keep the logger alive past the runtime's stack frame.
3875    pub fn audit_log_arc(&self) -> Arc<crate::runtime::audit_log::AuditLogger> {
3876        Arc::clone(&self.inner.audit_log)
3877    }
3878
3879    pub(crate) fn emit_control_event(
3880        &self,
3881        kind: crate::runtime::control_events::EventKind,
3882        outcome: crate::runtime::control_events::Outcome,
3883        action: &'static str,
3884        resource: Option<String>,
3885        reason: Option<String>,
3886        extra_fields: Vec<(String, crate::runtime::control_events::Sensitivity)>,
3887    ) -> RedDBResult<()> {
3888        use crate::runtime::control_events::{
3889            ActorRef, ControlEvent, ControlEventCtx, ControlEventLedger, Sensitivity,
3890        };
3891
3892        let tenant = current_tenant();
3893        let principal = current_auth_identity();
3894        let actor_user = principal
3895            .as_ref()
3896            .map(|(principal, _)| UserId::from_parts(tenant.as_deref(), principal));
3897        let actor = actor_user
3898            .as_ref()
3899            .map(ActorRef::User)
3900            .unwrap_or(ActorRef::Anonymous);
3901        let ctx = ControlEventCtx {
3902            actor,
3903            scope: tenant
3904                .as_ref()
3905                .map(|scope| std::borrow::Cow::Borrowed(scope.as_str())),
3906            request_id: Some(std::borrow::Cow::Owned(format!(
3907                "conn-{}",
3908                current_connection_id()
3909            ))),
3910            trace_id: None,
3911        };
3912        let mut fields = std::collections::HashMap::new();
3913        fields.insert(
3914            "connection_id".to_string(),
3915            Sensitivity::raw(current_connection_id().to_string()),
3916        );
3917        if let Some((_, role)) = principal {
3918            fields.insert("actor_role".to_string(), Sensitivity::raw(role.as_str()));
3919        }
3920        for (key, value) in extra_fields {
3921            fields.insert(key, value);
3922        }
3923        let event = ControlEvent {
3924            kind,
3925            outcome,
3926            action: std::borrow::Cow::Borrowed(action),
3927            resource,
3928            reason,
3929            matched_policy_id: None,
3930            fields,
3931        };
3932        let ledger = self.inner.control_event_ledger.read();
3933        match ledger.emit(&ctx, event) {
3934            Ok(_) => Ok(()),
3935            Err(err) if self.inner.control_event_config.require_persistence() => {
3936                Err(RedDBError::Internal(err.to_string()))
3937            }
3938            Err(_) => Ok(()),
3939        }
3940    }
3941
3942    fn policy_mutation_control_ctx<'a>(
3943        &self,
3944        actor: &'a crate::auth::UserId,
3945        tenant: Option<&'a str>,
3946    ) -> crate::runtime::control_events::ControlEventCtx<'a> {
3947        crate::runtime::control_events::ControlEventCtx {
3948            actor: crate::runtime::control_events::ActorRef::User(actor),
3949            scope: tenant.map(std::borrow::Cow::Borrowed),
3950            request_id: Some(std::borrow::Cow::Owned(format!(
3951                "conn-{}",
3952                current_connection_id()
3953            ))),
3954            trace_id: None,
3955        }
3956    }
3957
3958    fn emit_query_audit(
3959        &self,
3960        query: &str,
3961        plan: &QueryAuditPlan,
3962        duration_ms: u64,
3963        result: &RuntimeQueryResult,
3964    ) {
3965        if !self.inner.query_audit.has_rules() {
3966            return;
3967        }
3968        let actor = current_auth_identity().map(|(principal, _)| principal);
3969        let tenant = current_tenant();
3970        let row_count = if result.statement_type == "select" {
3971            result.result.records.len() as u64
3972        } else {
3973            result.affected_rows
3974        };
3975        self.inner
3976            .query_audit
3977            .emit(crate::runtime::query_audit::QueryAuditEvent {
3978                actor,
3979                tenant,
3980                statement_kind: plan.statement_kind,
3981                touched_collections: plan.collections.clone(),
3982                duration_ms,
3983                row_count,
3984                request_id: Some(crate::crypto::uuid::Uuid::new_v7().to_string()),
3985                query_hash: Some(blake3::hash(query.as_bytes()).to_hex().to_string()),
3986            });
3987    }
3988
3989    /// Shared queue telemetry counters (delivered/acked/nacked).
3990    pub(crate) fn queue_telemetry(
3991        &self,
3992    ) -> &crate::runtime::queue_telemetry::QueueTelemetryCounters {
3993        &self.inner.queue_telemetry
3994    }
3995
3996    /// Snapshots of the queue telemetry counters in label-deterministic
3997    /// order for `/metrics` rendering and the integration test.
3998    pub fn queue_telemetry_snapshot(
3999        &self,
4000    ) -> crate::runtime::queue_telemetry::QueueTelemetrySnapshot {
4001        crate::runtime::queue_telemetry::QueueTelemetrySnapshot {
4002            delivered: self.inner.queue_telemetry.delivered_snapshot(),
4003            acked: self.inner.queue_telemetry.acked_snapshot(),
4004            nacked: self.inner.queue_telemetry.nacked_snapshot(),
4005            wait_started: self.inner.queue_telemetry.wait_started_snapshot(),
4006            wait_woken: self.inner.queue_telemetry.wait_woken_snapshot(),
4007            wait_timed_out: self.inner.queue_telemetry.wait_timed_out_snapshot(),
4008            wait_cancelled: self.inner.queue_telemetry.wait_cancelled_snapshot(),
4009            wait_duration: self.inner.queue_telemetry.wait_duration_snapshot(),
4010        }
4011    }
4012
4013    /// Snapshots of Concurrent claim counters in label-deterministic order.
4014    pub fn claim_telemetry_snapshot(&self) -> crate::runtime::ClaimTelemetrySnapshot {
4015        self.inner.claim_telemetry.snapshot()
4016    }
4017
4018    /// Per-`kind` query latency histograms for `/metrics` (only kinds with
4019    /// a real sample are present — empty kinds are absent, not zero-filled).
4020    pub fn query_latency_snapshot(
4021        &self,
4022    ) -> Vec<crate::runtime::query_latency_telemetry::QueryLatencyHistogram> {
4023        self.inner.query_latency_telemetry.snapshot()
4024    }
4025
4026    /// Cross-kind query latency rollup for `/cluster/status` and the
4027    /// red-ui percentile panels. `count == 0` until a real sample exists.
4028    pub fn query_latency_rollup(
4029        &self,
4030    ) -> crate::runtime::query_latency_telemetry::QueryLatencyHistogram {
4031        self.inner.query_latency_telemetry.rollup()
4032    }
4033
4034    /// Issue #1244 — take a fresh node CPU/RAM occupancy reading for
4035    /// `/cluster/status`. CPU utilisation is measured over the interval
4036    /// since the previous call (the first call only establishes a baseline
4037    /// and reports `NotSampled`). See `occupancy_sampler` for overhead.
4038    pub fn sample_occupancy(&self) -> crate::runtime::occupancy_sampler::OccupancySample {
4039        self.inner.occupancy_sampler.sample()
4040    }
4041
4042    /// Issue #1245 — point-in-time node load snapshot (active queries +
4043    /// connect/disconnect churn). Feeds `/metrics`, `/cluster/status`, and
4044    /// the red-ui load panels.
4045    pub fn node_load_snapshot(&self) -> crate::runtime::node_load_telemetry::NodeLoadSnapshot {
4046        self.inner.node_load_telemetry.snapshot()
4047    }
4048
4049    /// Issue #742 — consumer presence registry. Heartbeats land here
4050    /// from `QUEUE READ` (and, in a follow-up slice, an explicit
4051    /// `QUEUE HEARTBEAT` command); Red UI and `red.queue_consumers`
4052    /// read snapshots through `queue_consumer_presence_snapshot`.
4053    pub(crate) fn queue_presence(
4054        &self,
4055    ) -> &std::sync::Arc<crate::storage::queue::presence::ConsumerPresenceRegistry> {
4056        &self.inner.queue_presence
4057    }
4058
4059    /// Issue #742 — point-in-time presence snapshot, classifying each
4060    /// `(queue, group, consumer)` as active/stale/expired against the
4061    /// supplied TTL. Wall-clock is read once here so the lifecycle
4062    /// flags inside the snapshot are internally consistent.
4063    pub fn queue_consumer_presence_snapshot(
4064        &self,
4065        ttl_ms: u64,
4066    ) -> Vec<crate::storage::queue::presence::ConsumerPresence> {
4067        let now_ns = std::time::SystemTime::now()
4068            .duration_since(std::time::UNIX_EPOCH)
4069            .map(|d| d.as_nanos() as u64)
4070            .unwrap_or(0);
4071        self.inner.queue_presence.snapshot(now_ns, ttl_ms)
4072    }
4073
4074    /// Issue #742 — active-consumer count per `(queue, group)` for the
4075    /// queue-metadata surface. Stale/expired entries are excluded by
4076    /// definition; they are still visible in the per-row snapshot.
4077    pub fn queue_active_consumer_counts(
4078        &self,
4079        ttl_ms: u64,
4080    ) -> std::collections::HashMap<(String, String), u32> {
4081        let now_ns = std::time::SystemTime::now()
4082            .duration_since(std::time::UNIX_EPOCH)
4083            .map(|d| d.as_nanos() as u64)
4084            .unwrap_or(0);
4085        self.inner
4086            .queue_presence
4087            .count_active_by_group(now_ns, ttl_ms)
4088    }
4089
4090    /// Issue #743 — vector + TurboQuant introspection registry. Engine
4091    /// publish points (collection create, artifact build start /
4092    /// finish, fallback toggle, drop) update this; Red UI and
4093    /// `red.*` vector virtual tables read snapshots through
4094    /// `vector_introspection_snapshot` / `vector_introspection_get`.
4095    pub(crate) fn vector_introspection_registry(
4096        &self,
4097    ) -> &std::sync::Arc<crate::storage::vector::introspection::VectorIntrospectionRegistry> {
4098        &self.inner.vector_introspection
4099    }
4100
4101    /// Issue #743 — full snapshot of every tracked vector collection's
4102    /// `(VectorMetadata, ArtifactMetadata)`. Deterministically ordered
4103    /// by collection name so Red UI tables and tests both see a
4104    /// stable shape.
4105    pub fn vector_introspection_snapshot(
4106        &self,
4107    ) -> Vec<crate::storage::vector::introspection::VectorIntrospection> {
4108        self.inner.vector_introspection.snapshot()
4109    }
4110
4111    /// Issue #743 — single-collection lookup, for the per-collection
4112    /// metadata endpoint Red UI hits when an operator opens one
4113    /// vector's toolbar.
4114    pub fn vector_introspection_get(
4115        &self,
4116        collection: &str,
4117    ) -> Option<crate::storage::vector::introspection::VectorIntrospection> {
4118        self.inner.vector_introspection.get(collection)
4119    }
4120
4121    /// Issue #1238 — ADR 0060 read-model accessor for slow-query telemetry.
4122    ///
4123    /// Returns a reference to the bounded ring store so HTTP handlers and
4124    /// the red-ui read model can call `store.read(filter)` without
4125    /// touching `red-slow.log` directly.
4126    pub fn slow_query_store(&self) -> &Arc<crate::telemetry::slow_query_store::SlowQueryStore> {
4127        &self.inner.slow_query_store
4128    }
4129
4130    /// Slice 10 of issue #527 — render-time scan of pending entries
4131    /// per (queue, group) for the `queue_pending_gauge` exposition.
4132    /// Walks `red_queue_meta` live so the gauge cannot drift from
4133    /// the source of truth.
4134    pub fn queue_pending_counts(&self) -> Vec<((String, String), u64)> {
4135        let store = self.inner.db.store();
4136        crate::runtime::impl_queue::pending_counts_by_group(store.as_ref())
4137            .into_iter()
4138            .collect()
4139    }
4140
4141    /// Shared `Arc` to the write gate. Same rationale as
4142    /// `audit_log_arc`: collaborators (lease lifecycle, refresh
4143    /// thread) need a clone-cheap handle they can move into a
4144    /// background thread.
4145    pub fn write_gate_arc(&self) -> Arc<crate::runtime::write_gate::WriteGate> {
4146        Arc::clone(&self.inner.write_gate)
4147    }
4148
4149    /// Serverless writer-lease state machine. `None` when the operator
4150    /// did not opt into lease fencing (`RED_LEASE_REQUIRED` unset).
4151    pub fn lease_lifecycle(&self) -> Option<&Arc<crate::runtime::lease_lifecycle::LeaseLifecycle>> {
4152        self.inner.lease_lifecycle.get()
4153    }
4154
4155    /// Install the lease lifecycle. Idempotent; subsequent calls
4156    /// return the previously stored value untouched.
4157    pub fn set_lease_lifecycle(
4158        &self,
4159        lifecycle: Arc<crate::runtime::lease_lifecycle::LeaseLifecycle>,
4160    ) -> Result<(), Arc<crate::runtime::lease_lifecycle::LeaseLifecycle>> {
4161        self.inner.lease_lifecycle.set(lifecycle)
4162    }
4163
4164    /// Reject the call when the requested batch size exceeds
4165    /// `RED_MAX_BATCH_SIZE`. Returns `RedDBError::QuotaExceeded`
4166    /// shaped so the HTTP layer can map it to 413 Payload Too
4167    /// Large (PLAN.md Phase 4.1).
4168    pub fn check_batch_size(&self, requested: usize) -> RedDBResult<()> {
4169        if self.inner.resource_limits.batch_size_exceeded(requested) {
4170            let max = self.inner.resource_limits.max_batch_size.unwrap_or(0);
4171            return Err(RedDBError::QuotaExceeded(format!(
4172                "max_batch_size:{requested}:{max}"
4173            )));
4174        }
4175        Ok(())
4176    }
4177
4178    /// Reject the call when the local DB file exceeds
4179    /// `RED_MAX_DB_SIZE_BYTES`. Reads file metadata once per call —
4180    /// the cost is a single `stat()` syscall, negligible against the
4181    /// I/O the caller is about to do. Returns `QuotaExceeded` shaped
4182    /// for HTTP 507 Insufficient Storage.
4183    pub fn check_db_size(&self) -> RedDBResult<()> {
4184        let Some(limit) = self.inner.resource_limits.max_db_size_bytes else {
4185            return Ok(());
4186        };
4187        if limit == 0 {
4188            return Ok(());
4189        }
4190        let Some(path) = self.inner.db.path() else {
4191            return Ok(());
4192        };
4193        let current = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
4194        if current > limit {
4195            return Err(RedDBError::QuotaExceeded(format!(
4196                "max_db_size_bytes:{current}:{limit}"
4197            )));
4198        }
4199        Ok(())
4200    }
4201
4202    /// Graceful shutdown coordinator (PLAN.md Phase 1.1).
4203    ///
4204    /// Steps, in order, all idempotent across re-entrant calls:
4205    ///   1. Move lifecycle into `ShuttingDown` (concurrent callers
4206    ///      observe `Stopped` after first finishes).
4207    ///   2. Flush WAL + run final checkpoint via `db.flush()` so
4208    ///      every acked write is durable on disk.
4209    ///   3. If `backup_on_shutdown == true` and a remote backend is
4210    ///      configured, run a synchronous `trigger_backup()` so the
4211    ///      remote head reflects the final state.
4212    ///   4. Stamp the report and move to `Stopped`. Subsequent calls
4213    ///      return the cached report without re-running anything.
4214    ///
4215    /// On any error, the runtime is still marked `Stopped` so the
4216    /// process can exit; the caller logs the error context but does
4217    /// not retry the same shutdown — the operator can inspect the
4218    /// report fields to see which step failed.
4219    pub fn graceful_shutdown(
4220        &self,
4221        backup_on_shutdown: bool,
4222    ) -> RedDBResult<crate::runtime::lifecycle::ShutdownReport> {
4223        if !self.inner.lifecycle.begin_shutdown() {
4224            // Someone else already shut down (or is in flight). Return
4225            // the cached report so the HTTP caller and SIGTERM handler
4226            // get the same idempotent answer.
4227            return Ok(self.inner.lifecycle.shutdown_report().unwrap_or_default());
4228        }
4229
4230        let started_ms = std::time::SystemTime::now()
4231            .duration_since(std::time::UNIX_EPOCH)
4232            .map(|d| d.as_millis() as u64)
4233            .unwrap_or(0);
4234        let mut report = crate::runtime::lifecycle::ShutdownReport {
4235            started_at_ms: started_ms,
4236            ..Default::default()
4237        };
4238
4239        // Flush WAL + run any pending checkpoint. Local fsync is
4240        // unconditional — even a lease-lost replica needs its WAL on
4241        // disk before exit so a future restore has the latest tail.
4242        // The remote upload is gated separately so a lost-lease writer
4243        // doesn't clobber the new holder's state on its way out.
4244        let flush_res = self.inner.db.flush_local_only();
4245        report.flushed_wal = flush_res.is_ok();
4246        report.final_checkpoint = flush_res.is_ok();
4247        if let Err(err) = &flush_res {
4248            tracing::error!(
4249                target: "reddb::lifecycle",
4250                error = %err,
4251                "graceful_shutdown: local flush failed"
4252            );
4253        } else if let Err(lease_err) =
4254            self.assert_remote_write_allowed("shutdown/checkpoint_upload")
4255        {
4256            tracing::warn!(
4257                target: "reddb::serverless::lease",
4258                error = %lease_err,
4259                "graceful_shutdown: remote upload skipped — lease not held"
4260            );
4261        } else if let Err(err) = self.inner.db.upload_to_remote_backend() {
4262            tracing::error!(
4263                target: "reddb::lifecycle",
4264                error = %err,
4265                "graceful_shutdown: remote upload failed"
4266            );
4267        }
4268
4269        // Optional final backup. Skipped silently when no remote
4270        // backend is configured — `trigger_backup()` returns Err
4271        // anyway in that case, but logging it as a shutdown failure
4272        // would be misleading on a standalone (no-backend) runtime.
4273        if backup_on_shutdown && self.inner.db.remote_backend.is_some() {
4274            // The trigger_backup gate now reads `WriteKind::Backup`,
4275            // which a replica/read_only instance refuses. That's
4276            // intentional — replicas don't drive backups; only the
4277            // primary does. We still want shutdown to flush its WAL
4278            // even if the backup branch is gated off.
4279            match self.trigger_backup() {
4280                Ok(result) => {
4281                    report.backup_uploaded = result.uploaded;
4282                }
4283                Err(err) => {
4284                    tracing::warn!(
4285                        target: "reddb::lifecycle",
4286                        error = %err,
4287                        "graceful_shutdown: final backup skipped"
4288                    );
4289                }
4290            }
4291        }
4292
4293        let completed_ms = std::time::SystemTime::now()
4294            .duration_since(std::time::UNIX_EPOCH)
4295            .map(|d| d.as_millis() as u64)
4296            .unwrap_or(started_ms);
4297        report.completed_at_ms = completed_ms;
4298        report.duration_ms = completed_ms.saturating_sub(started_ms);
4299
4300        self.inner.lifecycle.finish_shutdown(report.clone());
4301        Ok(report)
4302    }
4303
4304    /// PLAN.md Phase 4.4 — per-caller quota bucket. Always
4305    /// returned; `is_configured()` lets callers short-circuit.
4306    pub fn quota_bucket(&self) -> &crate::runtime::quota_bucket::QuotaBucket {
4307        &self.inner.quota_bucket
4308    }
4309
4310    /// PLAN.md Phase 6.3 — whether at-rest encryption is configured.
4311    /// Reads `RED_ENCRYPTION_KEY` / `RED_ENCRYPTION_KEY_FILE` lazily;
4312    /// returns `("enabled", None)` when a key is loadable, `("error", Some(msg))`
4313    /// when the operator set the env but it doesn't parse, and
4314    /// `("disabled", None)` when no key is configured. The pager
4315    /// hookup is deferred — this accessor surfaces the operator's
4316    /// intent for /admin/status without yet using the key in writes.
4317    pub fn encryption_at_rest_status(&self) -> (&'static str, Option<String>) {
4318        match crate::crypto::page_encryption::key_from_env() {
4319            Ok(Some(_)) => ("enabled", None),
4320            Ok(None) => ("disabled", None),
4321            Err(err) => ("error", Some(err)),
4322        }
4323    }
4324
4325    /// PLAN.md Phase 11.5 — current replica apply health label
4326    /// (`ok`, `gap`, `divergence`, `apply_error`, `connecting`,
4327    /// `stalled_gap`). Read from the persisted `red.replication.state`
4328    /// config key updated by the replica loop. Returns `None` on
4329    /// non-replica instances or when no apply has run yet.
4330    pub fn replica_apply_health(&self) -> Option<String> {
4331        let state = self.config_string("red.replication.state", "");
4332        if state.is_empty() {
4333            None
4334        } else {
4335            Some(state)
4336        }
4337    }
4338
4339    pub fn acquire(&self) -> RedDBResult<RuntimeConnection> {
4340        let mut pool = self
4341            .inner
4342            .pool
4343            .lock()
4344            .map_err(|e| RedDBError::Internal(format!("connection pool lock poisoned: {e}")))?;
4345        if pool.active >= self.inner.pool_config.max_connections {
4346            return Err(RedDBError::Internal(
4347                "connection pool exhausted".to_string(),
4348            ));
4349        }
4350
4351        let id = if let Some(id) = pool.idle.pop() {
4352            id
4353        } else {
4354            let id = pool.next_id;
4355            pool.next_id += 1;
4356            id
4357        };
4358        pool.active += 1;
4359        pool.total_checkouts += 1;
4360        drop(pool);
4361
4362        // Issue #1245 — record the connection acquisition after releasing
4363        // the pool lock so the lock hold time is unchanged.
4364        self.inner.node_load_telemetry.record_connect();
4365
4366        Ok(RuntimeConnection {
4367            id,
4368            inner: Arc::clone(&self.inner),
4369        })
4370    }
4371
4372    pub fn checkpoint(&self) -> RedDBResult<()> {
4373        // Local fsync always allowed — losing the lease shouldn't
4374        // prevent us from durably persisting what's already in memory.
4375        // The remote upload is the side-effect that risks clobbering a
4376        // peer's state, so it's behind the lease gate.
4377        self.inner.db.flush_local_only().map_err(|err| {
4378            // Issue #205 — local flush failure is a CheckpointFailed
4379            // operator-grade event. The local-flush path also covers
4380            // the WAL fsync we depend on, so a failure here doubles as
4381            // the WalFsyncFailed signal for the runtime entry point.
4382            let msg = err.to_string();
4383            crate::telemetry::operator_event::OperatorEvent::CheckpointFailed {
4384                lsn: 0,
4385                error: msg.clone(),
4386            }
4387            .emit_global();
4388            crate::telemetry::operator_event::OperatorEvent::WalFsyncFailed {
4389                path: "<flush_local_only>".to_string(),
4390                error: msg.clone(),
4391            }
4392            .emit_global();
4393            RedDBError::Engine(msg)
4394        })?;
4395        if let Err(err) = self.assert_remote_write_allowed("checkpoint") {
4396            tracing::warn!(
4397                target: "reddb::serverless::lease",
4398                error = %err,
4399                "checkpoint: skipping remote upload — lease not held"
4400            );
4401            return Ok(());
4402        }
4403        self.inner
4404            .db
4405            .upload_to_remote_backend()
4406            .map_err(|err| RedDBError::Engine(err.to_string()))
4407    }
4408
4409    /// Guard remote-mutating operations on the writer lease.
4410    /// Returns `Ok(())` when no remote backend is configured (the
4411    /// lease is irrelevant) or the lease state is `NotRequired` /
4412    /// `Held`. Returns `RedDBError::ReadOnly` when the lease is
4413    /// `NotHeld`, with an audit-friendly action label so the caller
4414    /// can record the rejection.
4415    pub(crate) fn assert_remote_write_allowed(&self, action: &str) -> RedDBResult<()> {
4416        if self.inner.db.remote_backend.is_none() {
4417            return Ok(());
4418        }
4419        match self.inner.write_gate.lease_state() {
4420            crate::runtime::write_gate::LeaseGateState::NotHeld => {
4421                self.inner.audit_log.record(
4422                    action,
4423                    "system",
4424                    "remote_backend",
4425                    "err: writer lease not held",
4426                    crate::json::Value::Null,
4427                );
4428                Err(RedDBError::ReadOnly(format!(
4429                    "writer lease not held — {action} blocked (serverless fence)"
4430                )))
4431            }
4432            _ => Ok(()),
4433        }
4434    }
4435
4436    pub fn run_maintenance(&self) -> RedDBResult<()> {
4437        self.inner
4438            .db
4439            .run_maintenance()
4440            .map_err(|err| RedDBError::Internal(err.to_string()))
4441    }
4442
4443    pub fn scan_collection(
4444        &self,
4445        collection: &str,
4446        cursor: Option<ScanCursor>,
4447        limit: usize,
4448    ) -> RedDBResult<ScanPage> {
4449        let store = self.inner.db.store();
4450        let manager = store
4451            .get_collection(collection)
4452            .ok_or_else(|| RedDBError::NotFound(collection.to_string()))?;
4453
4454        let mut entities = manager.query_all(|_| true);
4455        entities.sort_by_key(|entity| entity.id.raw());
4456
4457        let offset = cursor.map(|cursor| cursor.offset).unwrap_or(0);
4458        let total = entities.len();
4459        let end = total.min(offset.saturating_add(limit.max(1)));
4460        let items = if offset >= total {
4461            Vec::new()
4462        } else {
4463            entities[offset..end].to_vec()
4464        };
4465        let next = (end < total).then_some(ScanCursor { offset: end });
4466
4467        Ok(ScanPage {
4468            collection: collection.to_string(),
4469            items,
4470            next,
4471            total,
4472        })
4473    }
4474
4475    pub fn catalog(&self) -> CatalogModelSnapshot {
4476        self.inner.db.catalog_model_snapshot()
4477    }
4478
4479    pub fn catalog_consistency_report(&self) -> crate::catalog::CatalogConsistencyReport {
4480        self.inner.db.catalog_consistency_report()
4481    }
4482
4483    pub fn catalog_attention_summary(&self) -> CatalogAttentionSummary {
4484        crate::catalog::attention_summary(&self.catalog())
4485    }
4486
4487    pub fn collection_attention(&self) -> Vec<CollectionDescriptor> {
4488        crate::catalog::collection_attention(&self.catalog())
4489    }
4490
4491    pub fn index_attention(&self) -> Vec<CatalogIndexStatus> {
4492        crate::catalog::index_attention(&self.catalog())
4493    }
4494
4495    pub fn graph_projection_attention(&self) -> Vec<CatalogGraphProjectionStatus> {
4496        crate::catalog::graph_projection_attention(&self.catalog())
4497    }
4498
4499    pub fn analytics_job_attention(&self) -> Vec<CatalogAnalyticsJobStatus> {
4500        crate::catalog::analytics_job_attention(&self.catalog())
4501    }
4502
4503    pub fn stats(&self) -> RuntimeStats {
4504        let pool = runtime_pool_lock(self);
4505        RuntimeStats {
4506            active_connections: pool.active,
4507            idle_connections: pool.idle.len(),
4508            total_checkouts: pool.total_checkouts,
4509            paged_mode: self.inner.db.is_paged(),
4510            started_at_unix_ms: self.inner.started_at_unix_ms,
4511            store: self.inner.db.stats(),
4512            system: SystemInfo::collect(),
4513            result_blob_cache: self.inner.result_blob_cache.stats(),
4514            kv: self.inner.kv_stats.snapshot(),
4515            metrics_ingest: self.inner.metrics_ingest_stats.snapshot(),
4516        }
4517    }
4518
4519    pub(crate) fn record_metrics_ingest(
4520        &self,
4521        accepted_samples: u64,
4522        accepted_series: u64,
4523        rejected_samples: u64,
4524        rejected_series: u64,
4525    ) {
4526        self.inner.metrics_ingest_stats.record(
4527            accepted_samples,
4528            accepted_series,
4529            rejected_samples,
4530            rejected_series,
4531        );
4532    }
4533
4534    pub(crate) fn record_metrics_cardinality_budget_rejections(&self, rejected_series: u64) {
4535        self.inner
4536            .metrics_ingest_stats
4537            .record_cardinality_budget_rejections(rejected_series);
4538    }
4539
4540    pub(crate) fn record_metrics_tenant_activity(
4541        &self,
4542        tenant: &str,
4543        namespace: &str,
4544        operation: &str,
4545    ) {
4546        self.inner
4547            .metrics_tenant_activity_stats
4548            .record(tenant, namespace, operation);
4549    }
4550
4551    pub(crate) fn metrics_tenant_activity_snapshot(
4552        &self,
4553    ) -> Vec<crate::runtime::MetricsTenantActivityStats> {
4554        self.inner.metrics_tenant_activity_stats.snapshot()
4555    }
4556
4557    /// Execute a query under a typed scope override without embedding
4558    /// the tenant / user / role values into the SQL string. Use this
4559    /// from transport middleware (HTTP / gRPC / worker loops) where the
4560    /// scope is resolved from auth claims and the SQL is a parameterised
4561    /// template — avoids the string-concat injection risk of building
4562    /// `WITHIN TENANT '<id>' …` manually, and is drop-in compatible with
4563    /// prepared statements that didn't know about tenancy.
4564    ///
4565    /// Precedence matches the `WITHIN` clause: the passed `scope`
4566    /// overrides `SET LOCAL TENANT`, which overrides `SET TENANT`.
4567    /// The override is pushed on the thread-local scope stack for the
4568    /// duration of the call and popped on return — pool-shared
4569    /// connections cannot leak it across requests.
4570    pub fn execute_query_with_scope(
4571        &self,
4572        query: &str,
4573        scope: crate::runtime::within_clause::ScopeOverride,
4574    ) -> RedDBResult<RuntimeQueryResult> {
4575        if scope.is_empty() {
4576            return self.execute_query(query);
4577        }
4578        let _scope_guard = ScopeOverrideGuard::install(scope);
4579        self.execute_query(query)
4580    }
4581
4582    /// Issue #205 — single lifecycle exit for slow-query logging.
4583    ///
4584    /// `execute_query_inner` does the real work; this wrapper times it
4585    /// and, if elapsed exceeds the configured threshold, hands the
4586    /// triple `(QueryKind, elapsed_ms, sql_redacted, scope)` to the
4587    /// SlowQueryLogger. The threshold + sample_pct were captured at
4588    /// SlowQueryLogger construction (runtime startup), so the per-call
4589    /// cost on below-threshold paths is one relaxed atomic load.
4590    pub fn execute_query(&self, query: &str) -> RedDBResult<RuntimeQueryResult> {
4591        let started = std::time::Instant::now();
4592        self.inner.node_load_telemetry.query_start();
4593        let result = self.execute_query_inner(query);
4594        self.finish_query_lifecycle(query, started, result)
4595    }
4596
4597    /// Execute a SQL statement with already-decoded positional bind
4598    /// parameters. Transports should call this instead of parsing +
4599    /// binding on their side and then reaching for `execute_query_expr`:
4600    /// this entry keeps parameterized statements inside the same
4601    /// statement lifecycle as textual SQL (snapshot guard, config/secret
4602    /// guards, coarse auth, intent locks, slow-query logging, integrity
4603    /// tombstone filtering, and causal bookmarks).
4604    pub fn execute_query_with_params(
4605        &self,
4606        query: &str,
4607        params: &[Value],
4608    ) -> RedDBResult<RuntimeQueryResult> {
4609        if params.is_empty() {
4610            return self.execute_query(query);
4611        }
4612        let started = std::time::Instant::now();
4613        self.inner.node_load_telemetry.query_start();
4614        let result = self.execute_query_with_params_inner(query, params);
4615        self.finish_query_lifecycle(query, started, result)
4616    }
4617
4618    fn finish_query_lifecycle(
4619        &self,
4620        query: &str,
4621        started: std::time::Instant,
4622        mut result: RedDBResult<RuntimeQueryResult>,
4623    ) -> RedDBResult<RuntimeQueryResult> {
4624        // Issue #765 / S6 — filter integrity-tombstoned rows out of SELECT
4625        // results before they reach any consumer. Fast no-op (one relaxed
4626        // atomic load) unless an input-stream digest mismatch has tombstoned
4627        // a RID range on this store.
4628        if let Ok(ref mut query_result) = result {
4629            if query_result.statement_type == "select" {
4630                self.filter_integrity_tombstoned(&mut query_result.result);
4631            }
4632        }
4633        let elapsed_ms = started.elapsed().as_millis() as u64;
4634
4635        // Build EffectiveScope from the same thread-locals frame-build
4636        // consults — keeps the slow-log row consistent with the audit /
4637        // RLS view of "this statement". `ai_scope()` is the canonical
4638        // builder.
4639        let scope = self.ai_scope();
4640        let kind = match result
4641            .as_ref()
4642            .map(|r| r.statement_type)
4643            .unwrap_or("select")
4644        {
4645            "select" => crate::telemetry::slow_query_logger::QueryKind::Select,
4646            "insert" => crate::telemetry::slow_query_logger::QueryKind::Insert,
4647            "update" => crate::telemetry::slow_query_logger::QueryKind::Update,
4648            "delete" => crate::telemetry::slow_query_logger::QueryKind::Delete,
4649            _ => crate::telemetry::slow_query_logger::QueryKind::Internal,
4650        };
4651        // SQL redaction: pass the raw query through. The slow-query
4652        // logger writes structured JSON so embedded literals stay
4653        // escape-safe at the JSON boundary (proven by
4654        // `adversarial_sql_is_escape_safe` in slow_query_logger.rs).
4655        // PII redaction (e.g. literal masking) is a follow-up.
4656        self.inner
4657            .slow_query_logger
4658            .record(kind, elapsed_ms, query.to_string(), &scope);
4659
4660        // Issue #1241 — record latency into the bounded per-`kind`
4661        // histogram substrate (always, not only above the slow-query
4662        // threshold). `started.elapsed()` is re-read here for sub-ms
4663        // resolution; the cost is one `Instant::now` plus a handful of
4664        // relaxed atomic adds (see `query_latency_telemetry` docs).
4665        self.inner
4666            .query_latency_telemetry
4667            .observe(kind, started.elapsed().as_secs_f64());
4668
4669        // Issue #1245 — decrement the active-query gauge. One relaxed
4670        // atomic sub; the matching increment happened at execute_query /
4671        // execute_query_with_params entry.
4672        self.inner.node_load_telemetry.query_finish();
4673
4674        if let Ok(ref mut query_result) = result {
4675            if matches!(query_result.statement_type, "insert" | "update" | "delete") {
4676                let bookmark = crate::replication::CausalBookmark::new(
4677                    self.current_replication_term(),
4678                    self.cdc_current_lsn(),
4679                );
4680                query_result.bookmark = Some(bookmark.encode());
4681            }
4682        }
4683
4684        result
4685    }
4686
4687    fn execute_query_with_params_inner(
4688        &self,
4689        query: &str,
4690        params: &[Value],
4691    ) -> RedDBResult<RuntimeQueryResult> {
4692        let parsed = parse_multi(query).map_err(|err| RedDBError::Query(err.to_string()))?;
4693        let bound = crate::storage::query::user_params::bind(&parsed, params).map_err(|err| {
4694            RedDBError::Validation {
4695                message: err.to_string(),
4696                validation: crate::json!({
4697                    "code": "INVALID_PARAMS",
4698                    "surface": "query.params",
4699                }),
4700            }
4701        })?;
4702        self.execute_bound_query_expr_in_frame(query, bound)
4703    }
4704
4705    fn execute_bound_query_expr_in_frame(
4706        &self,
4707        query: &str,
4708        expr: QueryExpr,
4709    ) -> RedDBResult<RuntimeQueryResult> {
4710        let rewritten_query = super::red_schema::rewrite_virtual_names(query);
4711        let execution_query = rewritten_query.as_deref().unwrap_or(query);
4712        let frame = super::statement_frame::StatementExecutionFrame::build(self, execution_query)?;
4713        // Box the guards so the ~640-byte StatementFrameGuards struct lives on
4714        // the heap rather than the call stack — important for recursive paths
4715        // (view refresh, nested queries) where the stack can be as small as 2 MB.
4716        let _frame_guards = Box::new(frame.install(self));
4717        let _log_span = crate::telemetry::span::query_span(query).entered();
4718
4719        let expr = self.rewrite_view_refs(expr);
4720        let mode = detect_mode(execution_query);
4721        let control_event_specs = query_control_event_specs(&expr);
4722        let _lock_guard = match frame.prepare_dispatch(self, &expr) {
4723            Ok(guard) => guard,
4724            Err(err) => {
4725                let outcome = control_event_outcome_for_error(&err);
4726                for spec in &control_event_specs {
4727                    self.emit_control_event(
4728                        spec.kind,
4729                        outcome,
4730                        spec.action,
4731                        spec.resource.clone(),
4732                        Some(err.to_string()),
4733                        spec.fields.clone(),
4734                    )?;
4735                }
4736                return Err(err);
4737            }
4738        };
4739
4740        let mut result = self.dispatch_expr(expr, query, mode)?;
4741        if result.statement_type == "select" {
4742            self.apply_secret_decryption(&mut result);
4743        }
4744        Ok(result)
4745    }
4746
4747    pub fn causal_session(&self) -> crate::runtime::CausalSession {
4748        crate::runtime::CausalSession {
4749            runtime: self.clone(),
4750            bookmark: None,
4751            wait_timeout: std::time::Duration::from_secs(5),
4752        }
4753    }
4754
4755    pub fn wait_for_bookmark(
4756        &self,
4757        bookmark: &crate::replication::CausalBookmark,
4758        timeout: std::time::Duration,
4759    ) -> RedDBResult<()> {
4760        let deadline = std::time::Instant::now() + timeout;
4761        loop {
4762            let applied_lsn = self.local_contiguous_applied_lsn();
4763            if applied_lsn >= bookmark.commit_lsn() {
4764                return Ok(());
4765            }
4766            let now = std::time::Instant::now();
4767            if now >= deadline {
4768                return Err(RedDBError::InvalidOperation(format!(
4769                    "timed out waiting for causal bookmark lsn {}; applied={}",
4770                    bookmark.commit_lsn(),
4771                    applied_lsn
4772                )));
4773            }
4774            let remaining = deadline.saturating_duration_since(now);
4775            std::thread::sleep(remaining.min(std::time::Duration::from_millis(5)));
4776        }
4777    }
4778
4779    fn local_contiguous_applied_lsn(&self) -> u64 {
4780        match self.inner.db.options().replication.role {
4781            crate::replication::ReplicationRole::Replica { .. } => {
4782                self.config_u64("red.replication.last_applied_lsn", 0)
4783            }
4784            _ => self.cdc_current_lsn(),
4785        }
4786    }
4787
4788    fn execute_vcs_command(
4789        &self,
4790        query: &str,
4791        mode: QueryMode,
4792        cmd: RuntimeVcsCommand,
4793    ) -> RedDBResult<RuntimeQueryResult> {
4794        use crate::application::vcs::{
4795            Author, CheckoutInput, CheckoutTarget, CreateCommitInput, MergeInput, MergeOpts,
4796            ResetInput, ResetMode,
4797        };
4798        use crate::application::VcsUseCases;
4799
4800        let conn_id = current_connection_id();
4801        let vcs = VcsUseCases::new(self);
4802        let default_author = || Author {
4803            name: "rql".to_string(),
4804            email: "rql@reddb.io".to_string(),
4805        };
4806
4807        match cmd {
4808            RuntimeVcsCommand::Checkpoint { message, author } => {
4809                let author = author
4810                    .as_deref()
4811                    .map(parse_vcs_author)
4812                    .unwrap_or_else(default_author);
4813                let commit = vcs.commit(CreateCommitInput {
4814                    connection_id: conn_id,
4815                    message: message.clone(),
4816                    author,
4817                    committer: None,
4818                    amend: false,
4819                    allow_empty: true,
4820                })?;
4821                Ok(RuntimeQueryResult::ok_message(
4822                    query.to_string(),
4823                    &format!("checkpoint {}", commit.hash),
4824                    "vcs_checkpoint",
4825                ))
4826            }
4827            RuntimeVcsCommand::Checkout { target } => {
4828                let result = if target.starts_with("refs/tags/") {
4829                    vcs.checkout(CheckoutInput {
4830                        connection_id: conn_id,
4831                        target: CheckoutTarget::Tag(target.clone()),
4832                        force: false,
4833                    })
4834                } else if looks_like_commit_hash(&target) {
4835                    vcs.checkout(CheckoutInput {
4836                        connection_id: conn_id,
4837                        target: CheckoutTarget::Commit(target.clone()),
4838                        force: false,
4839                    })
4840                } else {
4841                    vcs.checkout(CheckoutInput {
4842                        connection_id: conn_id,
4843                        target: CheckoutTarget::Branch(target.clone()),
4844                        force: false,
4845                    })
4846                    .or_else(|_| {
4847                        vcs.checkout(CheckoutInput {
4848                            connection_id: conn_id,
4849                            target: CheckoutTarget::Commit(target.clone()),
4850                            force: false,
4851                        })
4852                    })
4853                }?;
4854                Ok(RuntimeQueryResult::ok_message(
4855                    query.to_string(),
4856                    &format!("checked out {}", result.name),
4857                    "vcs_checkout",
4858                ))
4859            }
4860            RuntimeVcsCommand::Reset {
4861                mode: reset_mode,
4862                target,
4863            } => {
4864                let mode = match reset_mode {
4865                    RuntimeVcsResetMode::Hard => ResetMode::Hard,
4866                    RuntimeVcsResetMode::Soft => ResetMode::Soft,
4867                    RuntimeVcsResetMode::Mixed => ResetMode::Mixed,
4868                };
4869                vcs.reset(ResetInput {
4870                    connection_id: conn_id,
4871                    target: target.clone(),
4872                    mode,
4873                })?;
4874                Ok(RuntimeQueryResult::ok_message(
4875                    query.to_string(),
4876                    "reset complete",
4877                    "vcs_reset",
4878                ))
4879            }
4880            RuntimeVcsCommand::Merge { branch } => {
4881                let outcome = vcs.merge(MergeInput {
4882                    connection_id: conn_id,
4883                    from: branch.clone(),
4884                    opts: MergeOpts::default(),
4885                    author: default_author(),
4886                })?;
4887                Ok(RuntimeQueryResult::ok_message(
4888                    query.to_string(),
4889                    &format!("merge complete; conflicts={}", outcome.conflicts.len()),
4890                    "vcs_merge",
4891                ))
4892            }
4893            RuntimeVcsCommand::CherryPick { commit } => {
4894                let outcome = vcs.cherry_pick(conn_id, &commit, default_author())?;
4895                Ok(RuntimeQueryResult::ok_message(
4896                    query.to_string(),
4897                    &format!(
4898                        "cherry-pick complete; conflicts={}",
4899                        outcome.conflicts.len()
4900                    ),
4901                    "vcs_cherry_pick",
4902                ))
4903            }
4904            RuntimeVcsCommand::Revert { commit } => {
4905                let reverted = vcs.revert(conn_id, &commit, default_author())?;
4906                Ok(RuntimeQueryResult::ok_message(
4907                    query.to_string(),
4908                    &format!("revert {}", reverted.hash),
4909                    "vcs_revert",
4910                ))
4911            }
4912            RuntimeVcsCommand::ResolveConflict { key, resolution } => {
4913                let value = match resolution {
4914                    RuntimeVcsConflictResolution::Ours => {
4915                        crate::json::Value::String("ours".to_string())
4916                    }
4917                    RuntimeVcsConflictResolution::Theirs => {
4918                        crate::json::Value::String("theirs".to_string())
4919                    }
4920                };
4921                vcs.conflict_resolve(&key, value)?;
4922                Ok(RuntimeQueryResult::ok_message(
4923                    query.to_string(),
4924                    "conflict resolved",
4925                    "vcs_resolve_conflict",
4926                ))
4927            }
4928        }
4929    }
4930
4931    #[inline(never)]
4932    fn execute_query_inner(&self, query: &str) -> RedDBResult<RuntimeQueryResult> {
4933        // ── ULTRA-TURBO: autocommit `SELECT * FROM t WHERE _entity_id = N` ──
4934        //
4935        // Moved above every boot-cost the normal path pays (WITHIN
4936        // strip, SET LOCAL parse, tx_local_tenants read, snapshot
4937        // guard, tracing span, tx_contexts read) because the bench's
4938        // `select_point` scenario was observed at 28× vs PostgreSQL —
4939        // the dominant cost wasn't the entity fetch but the ceremony
4940        // before it. Only fires when there's no ambient transaction
4941        // context or WITHIN override, so the snapshot install we skip
4942        // truly is a no-op for this query.
4943        if !has_scope_override_active()
4944            && !query.trim_start().starts_with("WITHIN")
4945            && !query.trim_start().starts_with("within")
4946            && !self.inner.query_audit.has_rules()
4947            && !self
4948                .inner
4949                .tx_contexts
4950                .read()
4951                .contains_key(&current_connection_id())
4952        {
4953            if let Some(result) = self.try_fast_entity_lookup(query) {
4954                return result;
4955            }
4956        }
4957
4958        // `WITHIN TENANT '<id>' [USER '<u>'] [AS ROLE '<r>'] <stmt>` —
4959        // strip the prefix, push a stack-scoped override, recurse on
4960        // the inner statement, pop on return. Stack lives in a
4961        // thread-local but is balanced by the RAII guard, so a
4962        // pool-shared connection cannot leak the override across
4963        // requests and an early `?` return still pops cleanly.
4964        match crate::runtime::within_clause::try_strip_within_prefix(query) {
4965            Ok(Some((scope, inner))) => {
4966                let _scope_guard = ScopeOverrideGuard::install(scope);
4967                // Re-enter the inner path, NOT `execute_query`, so the
4968                // slow-query lifecycle hook records exactly one row per
4969                // top-level statement (the WITHIN-stripped form would
4970                // double-record).
4971                return self.execute_query_inner(inner);
4972            }
4973            Ok(None) => {}
4974            Err(msg) => return Err(RedDBError::Query(msg)),
4975        }
4976
4977        // `EXPLAIN <stmt>` — introspection. Runs the planner on the
4978        // inner statement (WITHOUT executing it) and returns the
4979        // CanonicalLogicalNode tree as rows so the caller can see the
4980        // operator shape and estimated cost. `EXPLAIN ALTER FOR ...`
4981        // is a distinct schema-diff command and continues down the
4982        // regular SQL path.
4983        if let Some(inner) = strip_explain_prefix(query) {
4984            return self.explain_as_rows(query, inner);
4985        }
4986
4987        // `SET LOCAL TENANT '<id>'` — write the per-transaction tenant
4988        // override and return. Outside a transaction the statement is
4989        // an error (matches PG semantics: SET LOCAL only takes effect
4990        // within an active transaction).
4991        if let Some(value) = parse_set_local_tenant(query)? {
4992            let conn_id = current_connection_id();
4993            if !self.inner.tx_contexts.read().contains_key(&conn_id) {
4994                return Err(RedDBError::Query(
4995                    "SET LOCAL TENANT requires an active transaction".to_string(),
4996                ));
4997            }
4998            self.inner
4999                .tx_local_tenants
5000                .write()
5001                .insert(conn_id, value.clone());
5002            return Ok(RuntimeQueryResult::ok_message(
5003                query.to_string(),
5004                &match &value {
5005                    Some(id) => format!("local tenant set: {id}"),
5006                    None => "local tenant cleared".to_string(),
5007                },
5008                "set_local_tenant",
5009            ));
5010        }
5011
5012        if super::red_schema::is_system_schema_write(query) {
5013            return Err(RedDBError::Query(
5014                super::red_schema::READ_ONLY_ERROR.to_string(),
5015            ));
5016        }
5017
5018        if let Some(command) = parse_runtime_vcs_command(query) {
5019            return self.execute_vcs_command(query, detect_mode(query), command?);
5020        }
5021
5022        if let Some(create_source) = super::analytics_source_catalog::parse_create_statement(query)?
5023        {
5024            return self.execute_create_analytics_source(query, create_source);
5025        }
5026
5027        // Issue #790 — `READ METRIC <path>` is intentionally rejected at
5028        // v0. The descriptor itself is readable through
5029        // `red.analytics.metrics`; the *output* read returns a
5030        // structured error so callers can tell "execution engine not yet
5031        // built" apart from "metric does not exist".
5032        if let Some(path) = super::metric_descriptor_catalog::parse_read_metric_statement(query) {
5033            return Err(super::metric_descriptor_catalog::read_output_unsupported(
5034                &path,
5035            ));
5036        }
5037
5038        // Issue #918 / ADR 0035 — leaderboard rank capability catalog
5039        // declarations are still recognised before the general parser.
5040        // Rank reads themselves are parser AST nodes, including Redis-flavor
5041        // Z* sugar that desugars to the same canonical rank shapes.
5042        if let Some(parsed) = super::ranking_descriptor_catalog::parse_create_ranking(query) {
5043            return self.execute_create_ranking(query, parsed?);
5044        }
5045        if super::ranking_descriptor_catalog::parse_show_rankings(query) {
5046            return self.execute_show_rankings(query);
5047        }
5048
5049        let rewritten_query = super::red_schema::rewrite_virtual_names(query);
5050        let execution_query = rewritten_query.as_deref().unwrap_or(query);
5051
5052        let frame = super::statement_frame::StatementExecutionFrame::build(self, execution_query)?;
5053        let _frame_guards = Box::new(frame.install(self));
5054
5055        // Phase 6 logging: enter a span stamped with conn_id / tenant
5056        // / query_len. Every downstream tracing::info!/warn!/error!
5057        // inherits these fields — no need to thread them manually
5058        // through storage/scan layers. Entered AFTER the WITHIN /
5059        // SET LOCAL TENANT resolution above so the span reflects the
5060        // effective scope for this statement.
5061        let _log_span = crate::telemetry::span::query_span(query).entered();
5062
5063        // ── CTE prelude (#41) — `WITH x AS (...) SELECT ... FROM x` ──
5064        if let Some(rewritten) = frame.prepare_cte(execution_query)? {
5065            return self.execute_query_expr(rewritten);
5066        }
5067
5068        // ── TURBO: bypass SQL parse for SELECT * FROM x WHERE _entity_id = N ──
5069        if !self.inner.query_audit.has_rules() {
5070            if let Some(result) = self.try_fast_entity_lookup(execution_query) {
5071                return result;
5072            }
5073        }
5074
5075        // ── Result cache: return cached result if still fresh (30s TTL) ──
5076        if !self.inner.query_audit.has_rules() {
5077            if let Some(result) = frame.read_result_cache(self) {
5078                return Ok(result);
5079            }
5080        }
5081
5082        let prepared = frame.prepare_statement(self, execution_query)?;
5083        let mode = prepared.mode;
5084        let expr = prepared.expr;
5085
5086        let statement = query_expr_name(&expr);
5087        let result_cache_scopes = query_expr_result_cache_scopes(&expr);
5088        let control_event_specs = query_control_event_specs(&expr);
5089        let query_audit_plan = query_audit_plan(&expr);
5090
5091        let _lock_guard = match frame.prepare_dispatch(self, &expr) {
5092            Ok(guard) => guard,
5093            Err(err) => {
5094                let outcome = control_event_outcome_for_error(&err);
5095                for spec in &control_event_specs {
5096                    self.emit_control_event(
5097                        spec.kind,
5098                        outcome,
5099                        spec.action,
5100                        spec.resource.clone(),
5101                        Some(err.to_string()),
5102                        spec.fields.clone(),
5103                    )?;
5104                }
5105                return Err(err);
5106            }
5107        };
5108        let frame_iface: &dyn super::statement_frame::ReadFrame = &frame;
5109        let query_audit_started = std::time::Instant::now();
5110
5111        let query_result = match expr {
5112            QueryExpr::Graph(_) | QueryExpr::Path(_) => {
5113                // Apply MVCC visibility + RLS gate while materialising the
5114                // graph: every node entity is screened against the source
5115                // collection's policy chain (basic and `Nodes`-targeted)
5116                // and dropped when the caller's tenant / role doesn't
5117                // admit it. Edges are pruned automatically because the
5118                // graph builder skips edges whose endpoints aren't in
5119                // `allowed_nodes`.
5120                let (graph, node_properties, edge_properties) =
5121                    self.materialize_graph_with_rls()?;
5122                let result =
5123                    crate::storage::query::unified::UnifiedExecutor::execute_on_with_graph_properties(
5124                        &graph,
5125                        &expr,
5126                        node_properties,
5127                        edge_properties,
5128                    )
5129                        .map_err(|err| RedDBError::Query(err.to_string()))?;
5130
5131                Ok(RuntimeQueryResult {
5132                    query: query.to_string(),
5133                    mode,
5134                    statement,
5135                    engine: "materialized-graph",
5136                    result,
5137                    affected_rows: 0,
5138                    statement_type: "select",
5139                    bookmark: None,
5140                })
5141            }
5142            QueryExpr::Table(table) => {
5143                let table = self.resolve_table_expr_subqueries(
5144                    table,
5145                    &frame as &dyn super::statement_frame::ReadFrame,
5146                )?;
5147                // Table-valued functions (e.g. components(g)) dispatch to a
5148                // read-only executor before any catalog/virtual-table routing
5149                // (issue #795).
5150                if let Some(TableSource::Function {
5151                    name,
5152                    args,
5153                    named_args,
5154                }) = table.source.clone()
5155                {
5156                    // The graph-collection form is cacheable (issue #802): the
5157                    // result-cache read at the top of this function keys on the
5158                    // query string, and `result_cache_scopes` carries the graph
5159                    // collection (see `collect_table_source_scopes`) so a write
5160                    // to it invalidates the entry. Deterministic algorithm
5161                    // output is worth caching at any row count, so the write
5162                    // bypasses the generic ≤5-row payload heuristic.
5163                    let tvf_result = RuntimeQueryResult {
5164                        query: query.to_string(),
5165                        mode,
5166                        statement,
5167                        engine: "runtime-graph-tvf",
5168                        result: self.execute_table_function(&name, &args, &named_args)?,
5169                        affected_rows: 0,
5170                        statement_type: "select",
5171                        bookmark: None,
5172                    };
5173                    frame.write_result_cache(self, &tvf_result, result_cache_scopes.clone());
5174                    return Ok(tvf_result);
5175                }
5176                // Inline-graph TVF (issue #799): the graph is supplied by two
5177                // subqueries instead of a collection reference. Unlike the
5178                // graph-collection form, the result IS cacheable — its cache
5179                // key is the query string (the result-cache read at the top of
5180                // `execute_query_inner` keys on it) and `result_cache_scopes`
5181                // already carries the `nodes`/`edges` source collections, so a
5182                // write to any of them invalidates the entry.
5183                if let Some(TableSource::InlineGraphFunction {
5184                    name,
5185                    nodes,
5186                    edges,
5187                    named_args,
5188                }) = table.source.clone()
5189                {
5190                    let inline_result = RuntimeQueryResult {
5191                        query: query.to_string(),
5192                        mode,
5193                        statement,
5194                        engine: "runtime-graph-tvf-inline",
5195                        result: self.execute_inline_graph_function(
5196                            &name,
5197                            &nodes,
5198                            &edges,
5199                            &named_args,
5200                        )?,
5201                        affected_rows: 0,
5202                        statement_type: "select",
5203                        bookmark: None,
5204                    };
5205                    frame.write_result_cache(self, &inline_result, result_cache_scopes);
5206                    return Ok(inline_result);
5207                }
5208                if super::red_schema::is_virtual_table(&table.table) {
5209                    return Ok(RuntimeQueryResult {
5210                        query: query.to_string(),
5211                        mode,
5212                        statement,
5213                        engine: "runtime-red-schema",
5214                        result: super::red_schema::red_query(
5215                            self,
5216                            &table.table,
5217                            &table,
5218                            &frame as &dyn super::statement_frame::ReadFrame,
5219                        )?,
5220                        affected_rows: 0,
5221                        statement_type: "select",
5222                        bookmark: None,
5223                    });
5224                }
5225
5226                // `<graph>.<output>` analytics virtual view (issue #800).
5227                // Recomputed on demand — intentionally not result-cached, so it
5228                // always reflects the current graph data.
5229                if let Some(view_result) = self.try_resolve_analytics_view(
5230                    &table,
5231                    &frame as &dyn super::statement_frame::ReadFrame,
5232                )? {
5233                    return Ok(RuntimeQueryResult {
5234                        query: query.to_string(),
5235                        mode,
5236                        statement,
5237                        engine: "runtime-graph-analytics-view",
5238                        result: view_result,
5239                        affected_rows: 0,
5240                        statement_type: "select",
5241                        bookmark: None,
5242                    });
5243                }
5244
5245                if let Some(result) = self.execute_probabilistic_select(&table)? {
5246                    return Ok(RuntimeQueryResult {
5247                        query: query.to_string(),
5248                        mode,
5249                        statement,
5250                        engine: "runtime-probabilistic",
5251                        result,
5252                        affected_rows: 0,
5253                        statement_type: "select",
5254                        bookmark: None,
5255                    });
5256                }
5257
5258                // Foreign-table intercept (Phase 3.2.2 PG parity).
5259                //
5260                // When the referenced table matches a `CREATE FOREIGN TABLE`
5261                // registration, short-circuit into the FDW scan. Phase 3.2
5262                // wrappers don't yet support pushdown, so filters/projections
5263                // apply post-scan via `apply_foreign_table_filters` — good
5264                // enough for correctness; perf work lands in 3.2.3.
5265                if self.inner.foreign_tables.is_foreign_table(&table.table) {
5266                    let records = self
5267                        .inner
5268                        .foreign_tables
5269                        .scan(&table.table)
5270                        .map_err(|e| RedDBError::Internal(e.to_string()))?;
5271                    let result = apply_foreign_table_filters(records, &table);
5272                    return Ok(RuntimeQueryResult {
5273                        query: query.to_string(),
5274                        mode,
5275                        statement,
5276                        engine: "runtime-fdw",
5277                        result,
5278                        affected_rows: 0,
5279                        statement_type: "select",
5280                        bookmark: None,
5281                    });
5282                }
5283
5284                // Row-Level Security enforcement (Phase 2.5.2 PG parity).
5285                //
5286                // When RLS is enabled on this table, fetch every policy
5287                // that applies to the current (role, SELECT) pair and
5288                // fold them into the query's WHERE clause: policies
5289                // OR-combine (any of them admitting the row is enough),
5290                // then AND into the caller's existing filter.
5291                //
5292                // Anonymous callers (no thread-local identity) pass
5293                // `role = None`; policies with a specific `TO role`
5294                // clause skip, but `TO PUBLIC` policies still apply.
5295                //
5296                // When `inject_rls_filters` returns `None` the table has
5297                // RLS enabled but no policy admits the caller's role —
5298                // short-circuit with an empty result set instead of
5299                // synthesising a contradiction filter.
5300                let Some(table_with_rls) = self.authorize_relational_table_select(
5301                    table,
5302                    &frame as &dyn super::statement_frame::ReadFrame,
5303                )?
5304                else {
5305                    let empty = crate::storage::query::unified::UnifiedResult::empty();
5306                    return Ok(RuntimeQueryResult {
5307                        query: query.to_string(),
5308                        mode,
5309                        statement,
5310                        engine: "runtime-table-rls",
5311                        result: empty,
5312                        affected_rows: 0,
5313                        statement_type: "select",
5314                        bookmark: None,
5315                    });
5316                };
5317                Ok(RuntimeQueryResult {
5318                    query: query.to_string(),
5319                    mode,
5320                    statement,
5321                    engine: "runtime-table",
5322                    // #885: lend the frame-owned row-buffer arena to the
5323                    // streaming path so chunk buffers are reused across
5324                    // this statement's chunk-fetches instead of allocated
5325                    // fresh per chunk. This is the table-query dispatch
5326                    // that runs under a `StatementExecutionFrame`; the
5327                    // frameless prepared/subquery paths keep `None`.
5328                    result: execute_runtime_table_query_in(
5329                        &self.inner.db,
5330                        &table_with_rls,
5331                        Some(&self.inner.index_store),
5332                        Some(frame.row_arena()),
5333                    )?,
5334                    affected_rows: 0,
5335                    statement_type: "select",
5336                    bookmark: None,
5337                })
5338            }
5339            QueryExpr::Join(join) => {
5340                // Fold per-table RLS filters into each `QueryExpr::Table`
5341                // leaf of the join tree before executing. Without this
5342                // the join executor scans both tables raw and ignores
5343                // policies — a `WITHIN TENANT 'x'` against a join of
5344                // two tenant-scoped tables would leak cross-tenant rows.
5345                // When any leaf has RLS enabled and zero matching policy,
5346                // short-circuit to an empty join result instead of
5347                // emitting a contradiction filter.
5348                let join_with_rls = match self.authorize_relational_join_select(
5349                    join,
5350                    &frame as &dyn super::statement_frame::ReadFrame,
5351                )? {
5352                    Some(j) => j,
5353                    None => {
5354                        return Ok(RuntimeQueryResult {
5355                            query: query.to_string(),
5356                            mode,
5357                            statement,
5358                            engine: "runtime-join-rls",
5359                            result: crate::storage::query::unified::UnifiedResult::empty(),
5360                            affected_rows: 0,
5361                            statement_type: "select",
5362                            bookmark: None,
5363                        });
5364                    }
5365                };
5366                Ok(RuntimeQueryResult {
5367                    query: query.to_string(),
5368                    mode,
5369                    statement,
5370                    engine: "runtime-join",
5371                    result: execute_runtime_join_query(&self.inner.db, &join_with_rls)?,
5372                    affected_rows: 0,
5373                    statement_type: "select",
5374                    bookmark: None,
5375                })
5376            }
5377            QueryExpr::Vector(vector) => Ok(RuntimeQueryResult {
5378                query: query.to_string(),
5379                mode,
5380                statement,
5381                engine: "runtime-vector",
5382                result: execute_runtime_vector_query(&self.inner.db, &vector)?,
5383                affected_rows: 0,
5384                statement_type: "select",
5385                bookmark: None,
5386            }),
5387            QueryExpr::Hybrid(hybrid) => Ok(RuntimeQueryResult {
5388                query: query.to_string(),
5389                mode,
5390                statement,
5391                engine: "runtime-hybrid",
5392                result: execute_runtime_hybrid_query(&self.inner.db, &hybrid)?,
5393                affected_rows: 0,
5394                statement_type: "select",
5395                bookmark: None,
5396            }),
5397            QueryExpr::RankOf(ref rank) => self.execute_rank_of(query, rank),
5398            QueryExpr::ApproxRankOf(ref rank) => self.execute_approx_rank_of(query, rank),
5399            QueryExpr::RankRange(ref range) => self.execute_rank_range(query, range),
5400            // DML execution
5401            QueryExpr::Insert(ref insert) if super::red_schema::is_virtual_table(&insert.table) => {
5402                Err(RedDBError::Query(
5403                    super::red_schema::READ_ONLY_ERROR.to_string(),
5404                ))
5405            }
5406            QueryExpr::Update(ref update) if super::red_schema::is_virtual_table(&update.table) => {
5407                Err(RedDBError::Query(
5408                    super::red_schema::READ_ONLY_ERROR.to_string(),
5409                ))
5410            }
5411            QueryExpr::Delete(ref delete) if super::red_schema::is_virtual_table(&delete.table) => {
5412                Err(RedDBError::Query(
5413                    super::red_schema::READ_ONLY_ERROR.to_string(),
5414                ))
5415            }
5416            QueryExpr::Insert(ref insert) => self
5417                .with_deferred_store_wal_for_dml(self.insert_may_emit_events(insert), || {
5418                    self.execute_insert(query, insert)
5419                }),
5420            QueryExpr::Update(ref update) => self
5421                .with_deferred_store_wal_for_dml(self.update_may_emit_events(update), || {
5422                    self.execute_update(query, update)
5423                }),
5424            QueryExpr::Delete(ref delete) => self
5425                .with_deferred_store_wal_for_dml(self.delete_may_emit_events(delete), || {
5426                    self.execute_delete(query, delete)
5427                }),
5428            // DDL execution
5429            QueryExpr::CreateTable(ref create) => self.execute_create_table(query, create),
5430            QueryExpr::CreateCollection(ref create) => {
5431                self.execute_create_collection(query, create)
5432            }
5433            QueryExpr::CreateVector(ref create) => self.execute_create_vector(query, create),
5434            QueryExpr::DropTable(ref drop_tbl) => self.execute_drop_table(query, drop_tbl),
5435            QueryExpr::DropGraph(ref drop_graph) => self.execute_drop_graph(query, drop_graph),
5436            QueryExpr::DropVector(ref drop_vector) => self.execute_drop_vector(query, drop_vector),
5437            QueryExpr::DropDocument(ref drop_document) => {
5438                self.execute_drop_document(query, drop_document)
5439            }
5440            QueryExpr::DropKv(ref drop_kv) => self.execute_drop_kv(query, drop_kv),
5441            QueryExpr::DropCollection(ref drop_collection) => {
5442                self.execute_drop_collection(query, drop_collection)
5443            }
5444            QueryExpr::Truncate(ref truncate) => self.execute_truncate(query, truncate),
5445            QueryExpr::AlterTable(ref alter) => self.execute_alter_table(query, alter),
5446            QueryExpr::CreateVcsRef(ref create) => self.execute_create_vcs_ref(query, create),
5447            QueryExpr::DropVcsRef(ref drop_ref) => self.execute_drop_vcs_ref(query, drop_ref),
5448            QueryExpr::ExplainAlter(ref explain) => self.execute_explain_alter(query, explain),
5449            // Graph analytics commands
5450            QueryExpr::GraphCommand(ref cmd) => self.execute_graph_command(query, cmd),
5451            // Search commands
5452            QueryExpr::SearchCommand(ref cmd) => self.execute_search_command(query, cmd),
5453            // ASK: RAG query with LLM synthesis
5454            QueryExpr::Ask(ref ask) => self.execute_ask(query, ask),
5455            QueryExpr::CreateIndex(ref create_idx) => self.execute_create_index(query, create_idx),
5456            QueryExpr::DropIndex(ref drop_idx) => self.execute_drop_index(query, drop_idx),
5457            QueryExpr::ProbabilisticCommand(ref cmd) => {
5458                self.execute_probabilistic_command(query, cmd)
5459            }
5460            // Time-series DDL
5461            QueryExpr::CreateTimeSeries(ref ts) => self.execute_create_timeseries(query, ts),
5462            QueryExpr::CreateMetric(ref metric) => self.execute_create_metric(query, metric),
5463            QueryExpr::AlterMetric(ref alter) => self.execute_alter_metric(query, alter),
5464            QueryExpr::CreateSlo(ref slo) => self.execute_create_slo(query, slo),
5465            QueryExpr::DropTimeSeries(ref ts) => self.execute_drop_timeseries(query, ts),
5466            // Queue DDL and commands
5467            QueryExpr::CreateQueue(ref q) => self.execute_create_queue(query, q),
5468            QueryExpr::AlterQueue(ref q) => self.execute_alter_queue(query, q),
5469            QueryExpr::DropQueue(ref q) => self.execute_drop_queue(query, q),
5470            QueryExpr::QueueSelect(ref q) => self.execute_queue_select(query, q),
5471            QueryExpr::QueueCommand(ref cmd) => self
5472                .with_deferred_store_wal_if_transaction(|| self.execute_queue_command(query, cmd)),
5473            QueryExpr::EventsBackfill(ref backfill) => {
5474                self.execute_events_backfill(query, backfill)
5475            }
5476            QueryExpr::EventsBackfillStatus { ref collection } => Err(RedDBError::Query(format!(
5477                "EVENTS BACKFILL STATUS for '{collection}' is not implemented in this slice"
5478            ))),
5479            QueryExpr::KvCommand(ref cmd) => self.execute_kv_command(query, cmd),
5480            QueryExpr::ConfigCommand(ref cmd) => self.execute_config_command(query, cmd),
5481            QueryExpr::CreateTree(ref tree) => self.execute_create_tree(query, tree),
5482            QueryExpr::DropTree(ref tree) => self.execute_drop_tree(query, tree),
5483            QueryExpr::TreeCommand(ref cmd) => self.execute_tree_command(query, cmd),
5484            // SET CONFIG key = value
5485            QueryExpr::SetConfig { ref key, ref value } => {
5486                if key.starts_with("red.secret.") {
5487                    return Err(RedDBError::Query(
5488                        "red.secret.* is reserved for vault secrets; use SET SECRET".to_string(),
5489                    ));
5490                }
5491                if key.starts_with("red.secrets.") {
5492                    return Err(RedDBError::Query(
5493                        "red.secrets.* is reserved for vault secrets; use SET SECRET".to_string(),
5494                    ));
5495                }
5496                match self.check_managed_config_write_for_set_config(key) {
5497                    Err(err) => Err(err),
5498                    Ok(()) => {
5499                        let store = self.inner.db.store();
5500                        let json_val = match value {
5501                            Value::Text(s) => crate::serde_json::Value::String(s.to_string()),
5502                            Value::Integer(n) => crate::serde_json::Value::Number(*n as f64),
5503                            Value::Float(n) => crate::serde_json::Value::Number(*n),
5504                            Value::Boolean(b) => crate::serde_json::Value::Bool(*b),
5505                            _ => crate::serde_json::Value::String(value.to_string()),
5506                        };
5507                        store.set_config_tree(key, &json_val);
5508                        update_current_config_value(key, value.clone());
5509                        // Config changes can flip runtime behavior mid-session
5510                        // (auto_decrypt, auto_encrypt, etc.) — invalidate the
5511                        // result cache so subsequent reads re-execute against
5512                        // the new config.
5513                        self.invalidate_result_cache();
5514                        Ok(RuntimeQueryResult::ok_message(
5515                            query.to_string(),
5516                            &format!("config set: {key}"),
5517                            "set",
5518                        ))
5519                    }
5520                }
5521            }
5522            // SET SECRET key = value
5523            QueryExpr::SetSecret { ref key, ref value } => {
5524                if key.starts_with("red.config.") {
5525                    return Err(RedDBError::Query(
5526                        "red.config.* is reserved for config; use SET CONFIG".to_string(),
5527                    ));
5528                }
5529                let auth_store = self.inner.auth_store.read().clone().ok_or_else(|| {
5530                    RedDBError::Query("SET SECRET requires an enabled, unsealed vault".to_string())
5531                })?;
5532                self.check_secret_write_privilege(&auth_store, key)?;
5533                if matches!(value, Value::Null) {
5534                    auth_store
5535                        .vault_kv_try_delete(key)
5536                        .map_err(|err| RedDBError::Query(err.to_string()))?;
5537                    update_current_secret_value(key, None);
5538                    self.invalidate_result_cache();
5539                    return Ok(RuntimeQueryResult::ok_message(
5540                        query.to_string(),
5541                        &format!("secret deleted: {key}"),
5542                        "delete_secret",
5543                    ));
5544                }
5545                let value = secret_sql_value_to_string(value)?;
5546                auth_store
5547                    .vault_kv_try_set(key.clone(), value.clone())
5548                    .map_err(|err| RedDBError::Query(err.to_string()))?;
5549                update_current_secret_value(key, Some(value));
5550                self.invalidate_result_cache();
5551                Ok(RuntimeQueryResult::ok_message(
5552                    query.to_string(),
5553                    &format!("secret set: {key}"),
5554                    "set_secret",
5555                ))
5556            }
5557            // DELETE SECRET key
5558            QueryExpr::DeleteSecret { ref key } => {
5559                let auth_store = self.inner.auth_store.read().clone().ok_or_else(|| {
5560                    RedDBError::Query(
5561                        "DELETE SECRET requires an enabled, unsealed vault".to_string(),
5562                    )
5563                })?;
5564                self.check_secret_write_privilege(&auth_store, key)?;
5565                let deleted = auth_store
5566                    .vault_kv_try_delete(key)
5567                    .map_err(|err| RedDBError::Query(err.to_string()))?;
5568                if deleted {
5569                    update_current_secret_value(key, None);
5570                }
5571                self.invalidate_result_cache();
5572                Ok(RuntimeQueryResult::ok_message(
5573                    query.to_string(),
5574                    &format!("secret deleted: {key}"),
5575                    if deleted {
5576                        "delete_secret"
5577                    } else {
5578                        "delete_secret_not_found"
5579                    },
5580                ))
5581            }
5582            // SET KV key = value — plain (non-encrypted) user KV entry (#1602)
5583            QueryExpr::SetKv { ref key, ref value } => {
5584                let auth_store = self.inner.auth_store.read().clone().ok_or_else(|| {
5585                    RedDBError::Query("SET KV requires an auth store".to_string())
5586                })?;
5587                self.check_kv_write_privilege(&auth_store, key)?;
5588                // `SET KV k = NULL` deletes, mirroring `SET SECRET`.
5589                if matches!(value, Value::Null) {
5590                    auth_store.plain_kv_delete(key);
5591                    update_current_kv_value(key, None);
5592                    self.invalidate_result_cache();
5593                    return Ok(RuntimeQueryResult::ok_message(
5594                        query.to_string(),
5595                        &format!("kv deleted: {key}"),
5596                        "delete_kv",
5597                    ));
5598                }
5599                let value = secret_sql_value_to_string(value)?;
5600                auth_store.plain_kv_set(key.clone(), value.clone());
5601                update_current_kv_value(key, Some(value));
5602                self.invalidate_result_cache();
5603                Ok(RuntimeQueryResult::ok_message(
5604                    query.to_string(),
5605                    &format!("kv set: {key}"),
5606                    "set_kv",
5607                ))
5608            }
5609            // DELETE KV key
5610            QueryExpr::DeleteKv { ref key } => {
5611                let auth_store = self.inner.auth_store.read().clone().ok_or_else(|| {
5612                    RedDBError::Query("DELETE KV requires an auth store".to_string())
5613                })?;
5614                self.check_kv_write_privilege(&auth_store, key)?;
5615                let deleted = auth_store.plain_kv_delete(key);
5616                if deleted {
5617                    update_current_kv_value(key, None);
5618                }
5619                self.invalidate_result_cache();
5620                Ok(RuntimeQueryResult::ok_message(
5621                    query.to_string(),
5622                    &format!("kv deleted: {key}"),
5623                    if deleted {
5624                        "delete_kv"
5625                    } else {
5626                        "delete_kv_not_found"
5627                    },
5628                ))
5629            }
5630            // SHOW SECRET[S] [prefix]
5631            QueryExpr::ShowSecrets { ref prefix } => {
5632                let auth_store = self.inner.auth_store.read().clone().ok_or_else(|| {
5633                    RedDBError::Query("SHOW SECRET requires an enabled, unsealed vault".to_string())
5634                })?;
5635                if !auth_store.is_vault_backed() {
5636                    return Err(RedDBError::Query(
5637                        "SHOW SECRET requires an enabled, unsealed vault".to_string(),
5638                    ));
5639                }
5640                let mut keys = auth_store.vault_kv_keys();
5641                keys.sort();
5642                let mut result = UnifiedResult::with_columns(vec![
5643                    "key".into(),
5644                    "value".into(),
5645                    "status".into(),
5646                ]);
5647                for key in keys {
5648                    if !show_secrets_allows_key(&key) {
5649                        continue;
5650                    }
5651                    if let Some(ref pfx) = prefix {
5652                        if !key.starts_with(pfx) {
5653                            continue;
5654                        }
5655                    }
5656                    let mut record = UnifiedRecord::new();
5657                    record.set("key", Value::text(key));
5658                    record.set("value", Value::text("***"));
5659                    record.set("status", Value::text("active"));
5660                    result.push(record);
5661                }
5662                Ok(RuntimeQueryResult {
5663                    query: query.to_string(),
5664                    mode,
5665                    statement: "show_secrets",
5666                    engine: "runtime-secret",
5667                    result,
5668                    affected_rows: 0,
5669                    statement_type: "select",
5670                    bookmark: None,
5671                })
5672            }
5673            // SHOW CONFIG [prefix] [AS JSON|FORMAT JSON]
5674            QueryExpr::ShowConfig {
5675                ref prefix,
5676                as_json,
5677            } => {
5678                let store = self.inner.db.store();
5679                let all_collections = store.list_collections();
5680                if !all_collections.contains(&"red_config".to_string()) {
5681                    if as_json {
5682                        return Ok(show_config_json_result(
5683                            query,
5684                            mode,
5685                            prefix,
5686                            crate::serde_json::Value::Object(crate::serde_json::Map::new()),
5687                        ));
5688                    }
5689                    let result = UnifiedResult::with_columns(vec!["key".into(), "value".into()]);
5690                    return Ok(RuntimeQueryResult {
5691                        query: query.to_string(),
5692                        mode,
5693                        statement: "show_config",
5694                        engine: "runtime-config",
5695                        result,
5696                        affected_rows: 0,
5697                        statement_type: "select",
5698                        bookmark: None,
5699                    });
5700                }
5701                let manager = store
5702                    .get_collection("red_config")
5703                    .ok_or_else(|| RedDBError::NotFound("red_config".to_string()))?;
5704                let entities = manager.query_all(|_| true);
5705                let mut latest = std::collections::BTreeMap::<String, (u64, Value, Value)>::new();
5706                for entity in entities {
5707                    if let EntityData::Row(ref row) = entity.data {
5708                        if let Some(ref named) = row.named {
5709                            let key_val = named.get("key").cloned().unwrap_or(Value::Null);
5710                            let val = named.get("value").cloned().unwrap_or(Value::Null);
5711                            let key_str = match &key_val {
5712                                Value::Text(s) => s.as_ref(),
5713                                _ => continue,
5714                            };
5715                            if let Some(ref pfx) = prefix {
5716                                if !key_str.starts_with(pfx.as_str()) {
5717                                    continue;
5718                                }
5719                            }
5720                            let entity_id = entity.id.raw();
5721                            match latest.get(key_str) {
5722                                Some((prev_id, _, _)) if *prev_id > entity_id => {}
5723                                _ => {
5724                                    latest.insert(key_str.to_string(), (entity_id, key_val, val));
5725                                }
5726                            }
5727                        }
5728                    }
5729                }
5730                if as_json {
5731                    let mut tree = crate::serde_json::Value::Object(crate::serde_json::Map::new());
5732                    for (key, (_, _, val)) in latest {
5733                        let relative = match prefix {
5734                            Some(pfx) if key == *pfx => "",
5735                            Some(pfx) => key
5736                                .strip_prefix(pfx.as_str())
5737                                .and_then(|tail| tail.strip_prefix('.'))
5738                                .unwrap_or(key.as_str()),
5739                            None => key.as_str(),
5740                        };
5741                        insert_config_json_path(
5742                            &mut tree,
5743                            relative,
5744                            crate::presentation::entity_json::storage_value_to_json(&val),
5745                        );
5746                    }
5747                    return Ok(show_config_json_result(query, mode, prefix, tree));
5748                }
5749                let mut result = UnifiedResult::with_columns(vec!["key".into(), "value".into()]);
5750                for (_, key_val, val) in latest.into_values() {
5751                    let mut record = UnifiedRecord::new();
5752                    record.set("key", key_val);
5753                    record.set("value", val);
5754                    result.push(record);
5755                }
5756                Ok(RuntimeQueryResult {
5757                    query: query.to_string(),
5758                    mode,
5759                    statement: "show_config",
5760                    engine: "runtime-config",
5761                    result,
5762                    affected_rows: 0,
5763                    statement_type: "select",
5764                    bookmark: None,
5765                })
5766            }
5767            // Session-local multi-tenancy handle (Phase 2.5.3).
5768            //
5769            // SET TENANT 'id' / SET TENANT NULL / RESET TENANT — writes
5770            // the thread-local; SHOW TENANT returns it. Paired with the
5771            // CURRENT_TENANT() scalar for use in RLS policies.
5772            QueryExpr::SetTenant(ref value) => {
5773                match value {
5774                    Some(id) => set_current_tenant(id.clone()),
5775                    None => clear_current_tenant(),
5776                }
5777                Ok(RuntimeQueryResult::ok_message(
5778                    query.to_string(),
5779                    &match value {
5780                        Some(id) => format!("tenant set: {id}"),
5781                        None => "tenant cleared".to_string(),
5782                    },
5783                    "set_tenant",
5784                ))
5785            }
5786            QueryExpr::ShowTenant => {
5787                let mut result = UnifiedResult::with_columns(vec!["tenant".into()]);
5788                let mut record = UnifiedRecord::new();
5789                record.set(
5790                    "tenant",
5791                    current_tenant().map(Value::text).unwrap_or(Value::Null),
5792                );
5793                result.push(record);
5794                Ok(RuntimeQueryResult {
5795                    query: query.to_string(),
5796                    mode,
5797                    statement: "show_tenant",
5798                    engine: "runtime-tenant",
5799                    result,
5800                    affected_rows: 0,
5801                    statement_type: "select",
5802                    bookmark: None,
5803                })
5804            }
5805            // Transaction control (Phase 2.3 PG parity).
5806            //
5807            // BEGIN allocates a real `Xid` and stores a `TxnContext` keyed by
5808            // the current connection's id. COMMIT/ROLLBACK release it through
5809            // the `SnapshotManager` so future snapshots see the correct set of
5810            // active/aborted transactions.
5811            //
5812            // Tuple stamping (xmin/xmax) and read-path visibility filtering
5813            // land in Phase 2.3.2 — this dispatch only manages the snapshot
5814            // registry. Statements running outside a TxnContext still behave
5815            // as autocommit (xid=0 → visible to every snapshot).
5816            QueryExpr::TransactionControl(ref ctl) => {
5817                use crate::storage::query::ast::TxnControl;
5818                use crate::storage::transaction::snapshot::{TxnContext, Xid};
5819                use crate::storage::transaction::IsolationLevel;
5820
5821                // Phase 2.3 keys transactions by a thread-local connection id.
5822                // The stdio/gRPC paths wire a real per-connection id later;
5823                // for embedded use (one RedDBRuntime per process-ish caller)
5824                // we fall back to a deterministic placeholder.
5825                let conn_id = current_connection_id();
5826
5827                let (kind, msg) = match ctl {
5828                    TxnControl::Begin => {
5829                        let mgr = Arc::clone(&self.inner.snapshot_manager);
5830                        let xid = mgr.begin();
5831                        let snapshot = mgr.snapshot(xid);
5832                        let ctx = TxnContext {
5833                            xid,
5834                            isolation: IsolationLevel::SnapshotIsolation,
5835                            snapshot,
5836                            savepoints: Vec::new(),
5837                            released_sub_xids: Vec::new(),
5838                        };
5839                        self.inner.tx_contexts.write().insert(conn_id, ctx);
5840                        ("begin", format!("BEGIN — xid={xid} (snapshot isolation)"))
5841                    }
5842                    TxnControl::Commit => {
5843                        // SET LOCAL TENANT ends with the transaction.
5844                        self.inner.tx_local_tenants.write().remove(&conn_id);
5845                        let ctx = self.inner.tx_contexts.write().remove(&conn_id);
5846                        match ctx {
5847                            Some(ctx) => {
5848                                let mut own_xids = std::collections::HashSet::new();
5849                                own_xids.insert(ctx.xid);
5850                                for (_, sub) in &ctx.savepoints {
5851                                    own_xids.insert(*sub);
5852                                }
5853                                for sub in &ctx.released_sub_xids {
5854                                    own_xids.insert(*sub);
5855                                }
5856                                if let Err(err) = self.check_table_row_write_conflicts(
5857                                    conn_id,
5858                                    &ctx.snapshot,
5859                                    &own_xids,
5860                                ) {
5861                                    for (_, sub) in &ctx.savepoints {
5862                                        self.inner.snapshot_manager.rollback(*sub);
5863                                    }
5864                                    for sub in &ctx.released_sub_xids {
5865                                        self.inner.snapshot_manager.rollback(*sub);
5866                                    }
5867                                    self.inner.snapshot_manager.rollback(ctx.xid);
5868                                    self.revive_pending_versioned_updates(conn_id);
5869                                    self.revive_pending_tombstones(conn_id);
5870                                    self.discard_pending_kv_watch_events(conn_id);
5871                                    self.discard_pending_queue_wakes(conn_id);
5872                                    self.discard_pending_store_wal_actions(conn_id);
5873                                    self.release_pending_claim_locks(conn_id);
5874                                    return Err(err);
5875                                }
5876                                self.restore_pending_write_stamps(conn_id);
5877                                if let Err(err) = self.flush_pending_store_wal_actions(conn_id) {
5878                                    for (_, sub) in &ctx.savepoints {
5879                                        self.inner.snapshot_manager.rollback(*sub);
5880                                    }
5881                                    for sub in &ctx.released_sub_xids {
5882                                        self.inner.snapshot_manager.rollback(*sub);
5883                                    }
5884                                    self.inner.snapshot_manager.rollback(ctx.xid);
5885                                    self.revive_pending_versioned_updates(conn_id);
5886                                    self.revive_pending_tombstones(conn_id);
5887                                    self.discard_pending_kv_watch_events(conn_id);
5888                                    self.discard_pending_queue_wakes(conn_id);
5889                                    self.release_pending_claim_locks(conn_id);
5890                                    return Err(err);
5891                                }
5892                                // Phase 2.3.2e: commit every open sub-xid
5893                                // so they also become visible. Their
5894                                // work is promoted to the parent txn's
5895                                // result exactly like a RELEASE would
5896                                // have done.
5897                                for (_, sub) in &ctx.savepoints {
5898                                    self.inner.snapshot_manager.commit(*sub);
5899                                }
5900                                for sub in &ctx.released_sub_xids {
5901                                    self.inner.snapshot_manager.commit(*sub);
5902                                }
5903                                self.inner.snapshot_manager.commit(ctx.xid);
5904                                self.finalize_pending_versioned_updates(conn_id);
5905                                self.finalize_pending_tombstones(conn_id);
5906                                self.finalize_pending_kv_watch_events(conn_id);
5907                                self.finalize_pending_queue_wakes(conn_id);
5908                                self.release_pending_claim_locks(conn_id);
5909                                ("commit", format!("COMMIT — xid={} committed", ctx.xid))
5910                            }
5911                            None => (
5912                                "commit",
5913                                "COMMIT outside transaction — no-op (autocommit)".to_string(),
5914                            ),
5915                        }
5916                    }
5917                    TxnControl::Rollback => {
5918                        self.inner.tx_local_tenants.write().remove(&conn_id);
5919                        let ctx = self.inner.tx_contexts.write().remove(&conn_id);
5920                        match ctx {
5921                            Some(ctx) => {
5922                                // Phase 2.3.2e: abort every open sub-xid
5923                                // too so their writes stay hidden.
5924                                for (_, sub) in &ctx.savepoints {
5925                                    self.inner.snapshot_manager.rollback(*sub);
5926                                }
5927                                for sub in &ctx.released_sub_xids {
5928                                    self.inner.snapshot_manager.rollback(*sub);
5929                                }
5930                                self.inner.snapshot_manager.rollback(ctx.xid);
5931                                // Phase 2.3.2b: tuples that the txn had
5932                                // xmax-stamped become live again — wipe xmax
5933                                // back to 0 so later snapshots see them.
5934                                self.revive_pending_versioned_updates(conn_id);
5935                                self.revive_pending_tombstones(conn_id);
5936                                self.discard_pending_kv_watch_events(conn_id);
5937                                self.discard_pending_queue_wakes(conn_id);
5938                                self.discard_pending_store_wal_actions(conn_id);
5939                                self.release_pending_claim_locks(conn_id);
5940                                ("rollback", format!("ROLLBACK — xid={} aborted", ctx.xid))
5941                            }
5942                            None => (
5943                                "rollback",
5944                                "ROLLBACK outside transaction — no-op (autocommit)".to_string(),
5945                            ),
5946                        }
5947                    }
5948                    // Phase 2.3.2e: savepoints map onto sub-xids. Each
5949                    // SAVEPOINT allocates a fresh xid and pushes it
5950                    // onto the per-txn stack so subsequent writes can
5951                    // be selectively rolled back. RELEASE pops without
5952                    // aborting; ROLLBACK TO aborts the sub-xid (and
5953                    // any nested ones) + revives their tombstones.
5954                    TxnControl::Savepoint(name) => {
5955                        let mgr = Arc::clone(&self.inner.snapshot_manager);
5956                        let mut guard = self.inner.tx_contexts.write();
5957                        match guard.get_mut(&conn_id) {
5958                            Some(ctx) => {
5959                                let sub = mgr.begin();
5960                                ctx.savepoints.push((name.clone(), sub));
5961                                ("savepoint", format!("SAVEPOINT {name} — sub_xid={sub}"))
5962                            }
5963                            None => (
5964                                "savepoint",
5965                                "SAVEPOINT outside transaction — no-op".to_string(),
5966                            ),
5967                        }
5968                    }
5969                    TxnControl::ReleaseSavepoint(name) => {
5970                        let mut guard = self.inner.tx_contexts.write();
5971                        match guard.get_mut(&conn_id) {
5972                            Some(ctx) => {
5973                                let pos = ctx
5974                                    .savepoints
5975                                    .iter()
5976                                    .position(|(n, _)| n == name)
5977                                    .ok_or_else(|| {
5978                                        RedDBError::Internal(format!(
5979                                            "savepoint {name} does not exist"
5980                                        ))
5981                                    })?;
5982                                // RELEASE pops the named savepoint and
5983                                // any nested ones. Their sub-xids move
5984                                // to `released_sub_xids` so they commit
5985                                // (or roll back) alongside the parent
5986                                // xid — PG semantics: released
5987                                // savepoints still contribute their
5988                                // work, but their names are gone.
5989                                let released = ctx.savepoints.len() - pos;
5990                                let popped: Vec<Xid> = ctx
5991                                    .savepoints
5992                                    .split_off(pos)
5993                                    .into_iter()
5994                                    .map(|(_, x)| x)
5995                                    .collect();
5996                                ctx.released_sub_xids.extend(popped);
5997                                (
5998                                    "release_savepoint",
5999                                    format!("RELEASE SAVEPOINT {name} — {released} level(s)"),
6000                                )
6001                            }
6002                            None => (
6003                                "release_savepoint",
6004                                "RELEASE outside transaction — no-op".to_string(),
6005                            ),
6006                        }
6007                    }
6008                    TxnControl::RollbackToSavepoint(name) => {
6009                        let mgr = Arc::clone(&self.inner.snapshot_manager);
6010                        // Splice out the savepoint + nested ones under
6011                        // a narrow lock, then run the snapshot-manager
6012                        // + tombstone side-effects without the tx map
6013                        // held so nothing re-enters.
6014                        let drop_result: Option<(Xid, Vec<Xid>)> = {
6015                            let mut guard = self.inner.tx_contexts.write();
6016                            if let Some(ctx) = guard.get_mut(&conn_id) {
6017                                let pos = ctx
6018                                    .savepoints
6019                                    .iter()
6020                                    .position(|(n, _)| n == name)
6021                                    .ok_or_else(|| {
6022                                        RedDBError::Internal(format!(
6023                                            "savepoint {name} does not exist"
6024                                        ))
6025                                    })?;
6026                                let savepoint_xid = ctx.savepoints[pos].1;
6027                                let aborted: Vec<Xid> = ctx
6028                                    .savepoints
6029                                    .split_off(pos)
6030                                    .into_iter()
6031                                    .map(|(_, x)| x)
6032                                    .collect();
6033                                Some((savepoint_xid, aborted))
6034                            } else {
6035                                None
6036                            }
6037                        };
6038
6039                        match drop_result {
6040                            Some((savepoint_xid, aborted)) => {
6041                                for x in &aborted {
6042                                    mgr.rollback(*x);
6043                                }
6044                                let reverted_updates =
6045                                    self.revive_versioned_updates_since(conn_id, savepoint_xid);
6046                                let revived = self.revive_tombstones_since(conn_id, savepoint_xid);
6047                                (
6048                                    "rollback_to_savepoint",
6049                                    format!(
6050                                        "ROLLBACK TO SAVEPOINT {name} — aborted {} sub_xid(s), reverted {reverted_updates} update(s), revived {revived} tombstone(s)",
6051                                        aborted.len(),
6052                                    ),
6053                                )
6054                            }
6055                            None => (
6056                                "rollback_to_savepoint",
6057                                "ROLLBACK TO outside transaction — no-op".to_string(),
6058                            ),
6059                        }
6060                    }
6061                };
6062                Ok(RuntimeQueryResult::ok_message(
6063                    query.to_string(),
6064                    &msg,
6065                    kind,
6066                ))
6067            }
6068            // Schema + Sequence DDL (Phase 1.3 PG parity).
6069            //
6070            // Schemas are lightweight logical namespaces: a CREATE SCHEMA call
6071            // just registers the name in `red_config` under `schema.{name}`.
6072            // Table lookups still happen by collection name; clients using
6073            // `schema.table` qualified names collapse to collection `schema.table`.
6074            //
6075            // Sequences persist a 64-bit counter + metadata (start, increment)
6076            // in `red_config` under `sequence.{name}.*`. Scalar callers
6077            // `nextval('name')` / `currval('name')` arrive with the MVCC phase
6078            // once we have a proper mutating-function dispatch path; for now the
6079            // DDL just establishes the catalog entry so clients don't error.
6080            QueryExpr::CreateSchema(ref q) => {
6081                let store = self.inner.db.store();
6082                let key = format!("schema.{}", q.name);
6083                if store.get_config(&key).is_some() {
6084                    if q.if_not_exists {
6085                        return Ok(RuntimeQueryResult::ok_message(
6086                            query.to_string(),
6087                            &format!("schema {} already exists — skipped", q.name),
6088                            "create_schema",
6089                        ));
6090                    }
6091                    return Err(RedDBError::Internal(format!(
6092                        "schema {} already exists",
6093                        q.name
6094                    )));
6095                }
6096                store.set_config_tree(&key, &crate::serde_json::Value::Bool(true));
6097                Ok(RuntimeQueryResult::ok_message(
6098                    query.to_string(),
6099                    &format!("schema {} created", q.name),
6100                    "create_schema",
6101                ))
6102            }
6103            QueryExpr::DropSchema(ref q) => {
6104                let store = self.inner.db.store();
6105                let key = format!("schema.{}", q.name);
6106                let existed = store.get_config(&key).is_some();
6107                if !existed && !q.if_exists {
6108                    return Err(RedDBError::Internal(format!(
6109                        "schema {} does not exist",
6110                        q.name
6111                    )));
6112                }
6113                // Remove marker from red_config via set to null.
6114                store.set_config_tree(&key, &crate::serde_json::Value::Null);
6115                let suffix = if q.cascade {
6116                    " (CASCADE accepted — tables untouched)"
6117                } else {
6118                    ""
6119                };
6120                Ok(RuntimeQueryResult::ok_message(
6121                    query.to_string(),
6122                    &format!("schema {} dropped{}", q.name, suffix),
6123                    "drop_schema",
6124                ))
6125            }
6126            QueryExpr::CreateSequence(ref q) => {
6127                let store = self.inner.db.store();
6128                let base = format!("sequence.{}", q.name);
6129                let start_key = format!("{base}.start");
6130                let incr_key = format!("{base}.increment");
6131                let curr_key = format!("{base}.current");
6132                if store.get_config(&start_key).is_some() {
6133                    if q.if_not_exists {
6134                        return Ok(RuntimeQueryResult::ok_message(
6135                            query.to_string(),
6136                            &format!("sequence {} already exists — skipped", q.name),
6137                            "create_sequence",
6138                        ));
6139                    }
6140                    return Err(RedDBError::Internal(format!(
6141                        "sequence {} already exists",
6142                        q.name
6143                    )));
6144                }
6145                // Persist start + increment, and set current so the first
6146                // nextval returns `start`.
6147                let initial_current = q.start - q.increment;
6148                store.set_config_tree(
6149                    &start_key,
6150                    &crate::serde_json::Value::Number(q.start as f64),
6151                );
6152                store.set_config_tree(
6153                    &incr_key,
6154                    &crate::serde_json::Value::Number(q.increment as f64),
6155                );
6156                store.set_config_tree(
6157                    &curr_key,
6158                    &crate::serde_json::Value::Number(initial_current as f64),
6159                );
6160                Ok(RuntimeQueryResult::ok_message(
6161                    query.to_string(),
6162                    &format!(
6163                        "sequence {} created (start={}, increment={})",
6164                        q.name, q.start, q.increment
6165                    ),
6166                    "create_sequence",
6167                ))
6168            }
6169            QueryExpr::DropSequence(ref q) => {
6170                let store = self.inner.db.store();
6171                let base = format!("sequence.{}", q.name);
6172                let existed = store.get_config(&format!("{base}.start")).is_some();
6173                if !existed && !q.if_exists {
6174                    return Err(RedDBError::Internal(format!(
6175                        "sequence {} does not exist",
6176                        q.name
6177                    )));
6178                }
6179                for k in ["start", "increment", "current"] {
6180                    store.set_config_tree(&format!("{base}.{k}"), &crate::serde_json::Value::Null);
6181                }
6182                Ok(RuntimeQueryResult::ok_message(
6183                    query.to_string(),
6184                    &format!("sequence {} dropped", q.name),
6185                    "drop_sequence",
6186                ))
6187            }
6188            // Views — CREATE [MATERIALIZED] VIEW (Phase 2.1 PG parity).
6189            //
6190            // The view definition is stored in-memory on RuntimeInner (not
6191            // persisted). SELECTs that reference the view name will substitute
6192            // the stored `QueryExpr` via `resolve_view_reference` during
6193            // planning (same entry point used by table-name resolution).
6194            //
6195            // Materialized views additionally allocate a slot in
6196            // `MaterializedViewCache`; a REFRESH repopulates that slot.
6197            QueryExpr::CreateView(ref q) => {
6198                let mut views = self.inner.views.write();
6199                if views.contains_key(&q.name) && !q.or_replace {
6200                    if q.if_not_exists {
6201                        return Ok(RuntimeQueryResult::ok_message(
6202                            query.to_string(),
6203                            &format!("view {} already exists — skipped", q.name),
6204                            "create_view",
6205                        ));
6206                    }
6207                    return Err(RedDBError::Internal(format!(
6208                        "view {} already exists",
6209                        q.name
6210                    )));
6211                }
6212                views.insert(q.name.clone(), Arc::new(q.clone()));
6213                drop(views);
6214
6215                // Materialized view: register cache slot (data is empty until REFRESH).
6216                if q.materialized {
6217                    use crate::storage::cache::result::{MaterializedViewDef, RefreshPolicy};
6218                    let refresh = match q.refresh_every_ms {
6219                        Some(ms) => RefreshPolicy::Periodic(std::time::Duration::from_millis(ms)),
6220                        None => RefreshPolicy::Manual,
6221                    };
6222                    let dependencies = collect_table_refs(&q.query);
6223                    let def = MaterializedViewDef {
6224                        name: q.name.clone(),
6225                        query: format!("<parsed view {}>", q.name),
6226                        dependencies: dependencies.clone(),
6227                        refresh,
6228                        retention_duration_ms: q.retention_duration_ms,
6229                    };
6230                    self.inner.materialized_views.write().register(def);
6231
6232                    // Issue #593 slice 9a — persist the descriptor to
6233                    // the system catalog so the definition survives a
6234                    // restart. Upsert semantics (delete-then-insert by
6235                    // name) keep the catalog free of duplicate rows
6236                    // across `CREATE OR REPLACE` churn.
6237                    let descriptor =
6238                        crate::runtime::continuous_materialized_view::MaterializedViewDescriptor {
6239                            name: q.name.clone(),
6240                            source_sql: query.to_string(),
6241                            source_collections: dependencies,
6242                            refresh_every_ms: q.refresh_every_ms,
6243                            retention_duration_ms: q.retention_duration_ms,
6244                        };
6245                    let store = self.inner.db.store();
6246                    crate::runtime::continuous_materialized_view::persist_descriptor(
6247                        store.as_ref(),
6248                        &descriptor,
6249                    )?;
6250
6251                    // Issue #594 slice 9b — provision a Table-shaped
6252                    // backing collection named after the view. The
6253                    // rewriter skips materialized views (see
6254                    // `rewrite_view_refs_inner`) so `SELECT FROM v`
6255                    // resolves to this collection directly. Empty
6256                    // until REFRESH wires through it in 9c.
6257                    self.ensure_materialized_view_backing(&q.name)?;
6258                }
6259                // Plan cache may have cached a plan that didn't know about this
6260                // view — invalidate so future references pick up the new binding.
6261                // Result cache gets flushed too: OR REPLACE must not serve a
6262                // prior execution of the obsolete body.
6263                self.invalidate_plan_cache();
6264                self.invalidate_result_cache();
6265
6266                Ok(RuntimeQueryResult::ok_message(
6267                    query.to_string(),
6268                    &format!(
6269                        "{}view {} created",
6270                        if q.materialized { "materialized " } else { "" },
6271                        q.name
6272                    ),
6273                    "create_view",
6274                ))
6275            }
6276            QueryExpr::DropView(ref q) => {
6277                let mut views = self.inner.views.write();
6278                let removed = views.remove(&q.name);
6279                let existed = removed.is_some();
6280                let removed_materialized =
6281                    removed.as_ref().map(|v| v.materialized).unwrap_or(false);
6282                drop(views);
6283                if q.materialized || existed {
6284                    // Try the materialised cache too — silent if absent.
6285                    self.inner.materialized_views.write().remove(&q.name);
6286                    // Issue #593 slice 9a — remove any persisted
6287                    // catalog row. Idempotent: a no-op when the view
6288                    // was never materialized (no row was ever written).
6289                    let store = self.inner.db.store();
6290                    crate::runtime::continuous_materialized_view::remove_by_name(
6291                        store.as_ref(),
6292                        &q.name,
6293                    )?;
6294                }
6295                // Issue #594 slice 9b — drop the backing collection
6296                // that was provisioned at CREATE time. Only mat views
6297                // ever had one; regular views never did.
6298                if removed_materialized || q.materialized {
6299                    self.drop_materialized_view_backing(&q.name)?;
6300                }
6301                // Drop any plan / result cache entries that baked the
6302                // view body into their QueryExpr.
6303                self.invalidate_plan_cache();
6304                self.invalidate_result_cache();
6305                if !existed && !q.if_exists {
6306                    return Err(RedDBError::Internal(format!(
6307                        "view {} does not exist",
6308                        q.name
6309                    )));
6310                }
6311                self.invalidate_plan_cache();
6312                Ok(RuntimeQueryResult::ok_message(
6313                    query.to_string(),
6314                    &format!("view {} dropped", q.name),
6315                    "drop_view",
6316                ))
6317            }
6318            QueryExpr::RefreshMaterializedView(ref q) => {
6319                // Look up the view definition, execute its underlying query,
6320                // and stash the serialized result in the materialised cache.
6321                let view = {
6322                    let views = self.inner.views.read();
6323                    views.get(&q.name).cloned()
6324                };
6325                let view = match view {
6326                    Some(v) => v,
6327                    None => {
6328                        return Err(RedDBError::Internal(format!(
6329                            "view {} does not exist",
6330                            q.name
6331                        )))
6332                    }
6333                };
6334                if !view.materialized {
6335                    return Err(RedDBError::Internal(format!(
6336                        "view {} is not materialized — REFRESH requires \
6337                         CREATE MATERIALIZED VIEW",
6338                        q.name
6339                    )));
6340                }
6341                // Execute the underlying query fresh.
6342                let started = std::time::Instant::now();
6343                let now_ms = std::time::SystemTime::now()
6344                    .duration_since(std::time::UNIX_EPOCH)
6345                    .map(|d| d.as_millis() as u64)
6346                    .unwrap_or(0);
6347                match self.execute_query_expr((*view.query).clone()) {
6348                    Ok(inner_result) => {
6349                        // Issue #595 slice 9c — atomically replace the
6350                        // backing collection's contents under a single
6351                        // WAL group. Concurrent SELECT from the view
6352                        // sees either the prior or new contents, never
6353                        // partial. A crash before the WAL commit lands
6354                        // leaves the prior contents intact on recovery.
6355                        let entities =
6356                            view_records_to_entities(&q.name, &inner_result.result.records);
6357                        let row_count = entities.len() as u64;
6358                        let store = self.inner.db.store();
6359                        let serialized_records = match store.refresh_collection(&q.name, entities) {
6360                            Ok(records) => records,
6361                            Err(err) => {
6362                                let duration_ms = started.elapsed().as_millis() as u64;
6363                                let msg = err.to_string();
6364                                self.inner
6365                                    .materialized_views
6366                                    .write()
6367                                    .record_refresh_failure(
6368                                        &q.name,
6369                                        msg.clone(),
6370                                        duration_ms,
6371                                        now_ms,
6372                                    );
6373                                return Err(RedDBError::Internal(format!(
6374                                    "REFRESH MATERIALIZED VIEW {}: {msg}",
6375                                    q.name
6376                                )));
6377                            }
6378                        };
6379
6380                        // Issue #596 slice 9d — emit a Refresh
6381                        // ChangeRecord into the logical-WAL spool so
6382                        // replicas deterministically replay the same
6383                        // backing-collection contents via
6384                        // `LogicalChangeApplier::apply_record`.
6385                        if let Some(ref primary) = self.inner.db.replication {
6386                            let lsn = self.inner.cdc.emit(
6387                                crate::replication::cdc::ChangeOperation::Refresh,
6388                                &q.name,
6389                                0,
6390                                "refresh",
6391                            );
6392                            self.invalidate_result_cache_for_table(&q.name);
6393                            let timestamp = std::time::SystemTime::now()
6394                                .duration_since(std::time::UNIX_EPOCH)
6395                                .unwrap_or_default()
6396                                .as_millis() as u64;
6397                            let record = ChangeRecord::for_refresh(
6398                                lsn,
6399                                timestamp,
6400                                q.name.clone(),
6401                                serialized_records,
6402                            )
6403                            .with_term(self.current_replication_term());
6404                            let encoded = record.encode();
6405                            primary.append_logical_record(record.lsn, encoded);
6406                        }
6407
6408                        let duration_ms = started.elapsed().as_millis() as u64;
6409                        let serialized = format!("{:?}", inner_result.result);
6410                        self.inner
6411                            .materialized_views
6412                            .write()
6413                            .record_refresh_success(
6414                                &q.name,
6415                                serialized.into_bytes(),
6416                                row_count,
6417                                duration_ms,
6418                                now_ms,
6419                            );
6420                        // SELECT FROM v now reads through the rewriter
6421                        // skip into the backing collection — drop the
6422                        // result cache so prior empty-backing reads
6423                        // don't shadow the new contents.
6424                        self.invalidate_result_cache();
6425                        Ok(RuntimeQueryResult::ok_message(
6426                            query.to_string(),
6427                            &format!("materialized view {} refreshed", q.name),
6428                            "refresh_materialized_view",
6429                        ))
6430                    }
6431                    Err(err) => {
6432                        let duration_ms = started.elapsed().as_millis() as u64;
6433                        let msg = err.to_string();
6434                        self.inner
6435                            .materialized_views
6436                            .write()
6437                            .record_refresh_failure(&q.name, msg.clone(), duration_ms, now_ms);
6438                        Err(err)
6439                    }
6440                }
6441            }
6442            // Row Level Security (Phase 2.5 PG parity).
6443            //
6444            // Policies live in an in-memory registry keyed by (table, name).
6445            // Enforcement (AND-ing the policy's USING clause into every
6446            // query's WHERE for the table) arrives in Phase 2.5.2 via the
6447            // filter compiler; this dispatch only manages the catalog.
6448            QueryExpr::CreatePolicy(ref q) => {
6449                let key = (q.table.clone(), q.name.clone());
6450                self.inner
6451                    .rls_policies
6452                    .write()
6453                    .insert(key, Arc::new(q.clone()));
6454                self.invalidate_plan_cache();
6455                // Issue #120 — surface policy names in the
6456                // schema-vocabulary so AskPipeline (#121) can resolve
6457                // a policy reference back to its table.
6458                self.schema_vocabulary_apply(
6459                    crate::runtime::schema_vocabulary::DdlEvent::CreatePolicy {
6460                        collection: q.table.clone(),
6461                        policy: q.name.clone(),
6462                    },
6463                );
6464                Ok(RuntimeQueryResult::ok_message(
6465                    query.to_string(),
6466                    &format!("policy {} on {} created", q.name, q.table),
6467                    "create_policy",
6468                ))
6469            }
6470            QueryExpr::DropPolicy(ref q) => {
6471                let removed = self
6472                    .inner
6473                    .rls_policies
6474                    .write()
6475                    .remove(&(q.table.clone(), q.name.clone()))
6476                    .is_some();
6477                if !removed && !q.if_exists {
6478                    return Err(RedDBError::Internal(format!(
6479                        "policy {} on {} does not exist",
6480                        q.name, q.table
6481                    )));
6482                }
6483                self.invalidate_plan_cache();
6484                // Issue #120 — keep the schema-vocabulary policy
6485                // entry in sync.
6486                self.schema_vocabulary_apply(
6487                    crate::runtime::schema_vocabulary::DdlEvent::DropPolicy {
6488                        collection: q.table.clone(),
6489                        policy: q.name.clone(),
6490                    },
6491                );
6492                Ok(RuntimeQueryResult::ok_message(
6493                    query.to_string(),
6494                    &format!("policy {} on {} dropped", q.name, q.table),
6495                    "drop_policy",
6496                ))
6497            }
6498            // Foreign Data Wrappers (Phase 3.2 PG parity).
6499            //
6500            // CREATE SERVER / CREATE FOREIGN TABLE register into the shared
6501            // `ForeignTableRegistry`. The read path consults that registry
6502            // before dispatching a SELECT — when the table name matches a
6503            // registered foreign table, we forward the scan to the wrapper
6504            // and skip the normal collection lookup.
6505            //
6506            // Phase 3.2 is in-memory only; persistence across restarts is a
6507            // 3.2.2 follow-up that mirrors the view registry pattern.
6508            QueryExpr::CreateServer(ref q) => {
6509                use crate::storage::fdw::FdwOptions;
6510                let registry = Arc::clone(&self.inner.foreign_tables);
6511                if registry.server(&q.name).is_some() {
6512                    if q.if_not_exists {
6513                        return Ok(RuntimeQueryResult::ok_message(
6514                            query.to_string(),
6515                            &format!("server {} already exists — skipped", q.name),
6516                            "create_server",
6517                        ));
6518                    }
6519                    return Err(RedDBError::Internal(format!(
6520                        "server {} already exists",
6521                        q.name
6522                    )));
6523                }
6524                let mut opts = FdwOptions::new();
6525                for (k, v) in &q.options {
6526                    opts.values.insert(k.clone(), v.clone());
6527                }
6528                registry
6529                    .create_server(&q.name, &q.wrapper, opts)
6530                    .map_err(|e| RedDBError::Internal(e.to_string()))?;
6531                Ok(RuntimeQueryResult::ok_message(
6532                    query.to_string(),
6533                    &format!("server {} created (wrapper {})", q.name, q.wrapper),
6534                    "create_server",
6535                ))
6536            }
6537            QueryExpr::DropServer(ref q) => {
6538                let existed = self.inner.foreign_tables.drop_server(&q.name);
6539                if !existed && !q.if_exists {
6540                    return Err(RedDBError::Internal(format!(
6541                        "server {} does not exist",
6542                        q.name
6543                    )));
6544                }
6545                Ok(RuntimeQueryResult::ok_message(
6546                    query.to_string(),
6547                    &format!(
6548                        "server {} dropped{}",
6549                        q.name,
6550                        if q.cascade { " (cascade)" } else { "" }
6551                    ),
6552                    "drop_server",
6553                ))
6554            }
6555            QueryExpr::CreateForeignTable(ref q) => {
6556                use crate::storage::fdw::{FdwOptions, ForeignColumn, ForeignTable};
6557                let registry = Arc::clone(&self.inner.foreign_tables);
6558                if registry.foreign_table(&q.name).is_some() {
6559                    if q.if_not_exists {
6560                        return Ok(RuntimeQueryResult::ok_message(
6561                            query.to_string(),
6562                            &format!("foreign table {} already exists — skipped", q.name),
6563                            "create_foreign_table",
6564                        ));
6565                    }
6566                    return Err(RedDBError::Internal(format!(
6567                        "foreign table {} already exists",
6568                        q.name
6569                    )));
6570                }
6571                let mut opts = FdwOptions::new();
6572                for (k, v) in &q.options {
6573                    opts.values.insert(k.clone(), v.clone());
6574                }
6575                let columns: Vec<ForeignColumn> = q
6576                    .columns
6577                    .iter()
6578                    .map(|c| ForeignColumn {
6579                        name: c.name.clone(),
6580                        data_type: c.data_type.clone(),
6581                        not_null: c.not_null,
6582                    })
6583                    .collect();
6584                registry
6585                    .create_foreign_table(ForeignTable {
6586                        name: q.name.clone(),
6587                        server_name: q.server.clone(),
6588                        columns,
6589                        options: opts,
6590                    })
6591                    .map_err(|e| RedDBError::Internal(e.to_string()))?;
6592                self.invalidate_plan_cache();
6593                Ok(RuntimeQueryResult::ok_message(
6594                    query.to_string(),
6595                    &format!("foreign table {} created (server {})", q.name, q.server),
6596                    "create_foreign_table",
6597                ))
6598            }
6599            QueryExpr::DropForeignTable(ref q) => {
6600                let existed = self.inner.foreign_tables.drop_foreign_table(&q.name);
6601                if !existed && !q.if_exists {
6602                    return Err(RedDBError::Internal(format!(
6603                        "foreign table {} does not exist",
6604                        q.name
6605                    )));
6606                }
6607                self.invalidate_plan_cache();
6608                Ok(RuntimeQueryResult::ok_message(
6609                    query.to_string(),
6610                    &format!("foreign table {} dropped", q.name),
6611                    "drop_foreign_table",
6612                ))
6613            }
6614            // COPY table FROM 'path' (Phase 1.5 PG parity).
6615            //
6616            // Stream CSV rows through the shared `CsvImporter`. The collection
6617            // is auto-created on first insert (via `insert_auto`-style path);
6618            // VACUUM/ANALYZE afterwards is up to the caller.
6619            QueryExpr::CopyFrom(ref q) => {
6620                use crate::storage::import::{CsvConfig, CsvImporter};
6621                let store = self.inner.db.store();
6622                let cfg = CsvConfig {
6623                    collection: q.table.clone(),
6624                    has_header: q.has_header,
6625                    delimiter: q.delimiter.map(|c| c as u8).unwrap_or(b','),
6626                    ..CsvConfig::default()
6627                };
6628                let importer = CsvImporter::new(cfg);
6629                let stats = importer
6630                    .import_file(&q.path, store.as_ref())
6631                    .map_err(|e| RedDBError::Internal(format!("COPY failed: {e}")))?;
6632                // Tables are written → invalidate cached plans / result cache.
6633                self.note_table_write(&q.table);
6634                Ok(RuntimeQueryResult::ok_message(
6635                    query.to_string(),
6636                    &format!(
6637                        "COPY imported {} rows into {} ({} errors skipped, {}ms)",
6638                        stats.records_imported, q.table, stats.errors_skipped, stats.duration_ms
6639                    ),
6640                    "copy_from",
6641                ))
6642            }
6643            // Maintenance commands (Phase 1.2 PG parity).
6644            //
6645            // - VACUUM [FULL] [table]: refreshes planner stats for the target
6646            //   collection(s) and — when FULL — triggers a full pager persist
6647            //   (flushes dirty pages + fsync). Also invalidates the result cache
6648            //   so subsequent reads re-execute against the freshly compacted
6649            //   storage. RedDB's segment/btree GC runs continuously via the
6650            //   background lifecycle; explicit space reclamation for sealed
6651            //   segments arrives with Phase 2.3 (MVCC + dead-tuple reclamation).
6652            // - ANALYZE [table]: reruns `analyze_collection` +
6653            //   `persist_table_stats` via `refresh_table_planner_stats` so the
6654            //   planner has fresh histograms, distinct estimates, null counts.
6655            //
6656            // Both commands accept an optional target; omitting the target
6657            // iterates every collection in the store.
6658            QueryExpr::MaintenanceCommand(ref cmd) => {
6659                use crate::storage::query::ast::MaintenanceCommand as Mc;
6660                let store = self.inner.db.store();
6661                let (kind, msg) = match cmd {
6662                    Mc::Analyze { target } => {
6663                        let targets: Vec<String> = match target {
6664                            Some(t) => vec![t.clone()],
6665                            None => store.list_collections(),
6666                        };
6667                        for t in &targets {
6668                            self.refresh_table_planner_stats(t);
6669                        }
6670                        (
6671                            "analyze",
6672                            format!("ANALYZE refreshed stats for {} table(s)", targets.len()),
6673                        )
6674                    }
6675                    Mc::Vacuum { target, full } => {
6676                        let targets: Vec<String> = match target {
6677                            Some(t) => vec![t.clone()],
6678                            None => store.list_collections(),
6679                        };
6680                        let cutoff_xid = self.mvcc_vacuum_cutoff_xid();
6681                        let mut vacuum_stats =
6682                            crate::storage::unified::store::MvccVacuumStats::default();
6683                        for t in &targets {
6684                            let stats = store.vacuum_mvcc_history(t, cutoff_xid).map_err(|e| {
6685                                RedDBError::Internal(format!(
6686                                    "VACUUM MVCC history failed for {t}: {e}"
6687                                ))
6688                            })?;
6689                            if stats.reclaimed_versions > 0 {
6690                                self.rebuild_runtime_indexes_for_table(t)?;
6691                            }
6692                            vacuum_stats.add(&stats);
6693                        }
6694                        self.inner.snapshot_manager.prune_aborted(cutoff_xid);
6695                        // Stats refresh covers every target (same as ANALYZE).
6696                        for t in &targets {
6697                            self.refresh_table_planner_stats(t);
6698                        }
6699                        // FULL forces a pager persist (dirty-page flush + fsync).
6700                        // Regular VACUUM relies on the background writer / segment
6701                        // lifecycle so the command is non-blocking.
6702                        let persisted = if *full {
6703                            match store.persist() {
6704                                Ok(()) => true,
6705                                Err(e) => {
6706                                    return Err(RedDBError::Internal(format!(
6707                                        "VACUUM FULL persist failed: {e:?}"
6708                                    )));
6709                                }
6710                            }
6711                        } else {
6712                            false
6713                        };
6714                        // Result cache depended on pre-vacuum state.
6715                        self.invalidate_result_cache();
6716                        (
6717                            "vacuum",
6718                            format!(
6719                                "VACUUM{} processed {} table(s): scanned_versions={}, retained_versions={}, reclaimed_versions={}, retained_history_versions={}, reclaimed_history_versions={}, retained_tombstones={}, reclaimed_tombstones={}{}",
6720                                if *full { " FULL" } else { "" },
6721                                targets.len(),
6722                                vacuum_stats.scanned_versions,
6723                                vacuum_stats.retained_versions,
6724                                vacuum_stats.reclaimed_versions,
6725                                vacuum_stats.retained_history_versions,
6726                                vacuum_stats.reclaimed_history_versions,
6727                                vacuum_stats.retained_tombstones,
6728                                vacuum_stats.reclaimed_tombstones,
6729                                if persisted {
6730                                    " (pages flushed to disk)"
6731                                } else {
6732                                    ""
6733                                }
6734                            ),
6735                        )
6736                    }
6737                };
6738                Ok(RuntimeQueryResult::ok_message(
6739                    query.to_string(),
6740                    &msg,
6741                    kind,
6742                ))
6743            }
6744            // GRANT / REVOKE / ALTER USER (RBAC milestone).
6745            //
6746            // These hit the AuthStore directly. The statement frame /
6747            // privilege gate has already decided whether the caller may
6748            // even run the statement; here we just translate the AST into
6749            // AuthStore calls.
6750            QueryExpr::Grant(ref g) => self.execute_grant_statement(query, g),
6751            QueryExpr::Revoke(ref r) => self.execute_revoke_statement(query, r),
6752            QueryExpr::AlterUser(ref a) => self.execute_alter_user_statement(query, a),
6753            QueryExpr::CreateUser(ref u) => self.execute_create_user_statement(query, u),
6754            QueryExpr::CreateIamPolicy { ref id, ref json } => {
6755                self.execute_create_iam_policy(query, id, json)
6756            }
6757            QueryExpr::DropIamPolicy { ref id } => self.execute_drop_iam_policy(query, id),
6758            QueryExpr::AttachPolicy {
6759                ref policy_id,
6760                ref principal,
6761            } => self.execute_attach_policy(query, policy_id, principal),
6762            QueryExpr::DetachPolicy {
6763                ref policy_id,
6764                ref principal,
6765            } => self.execute_detach_policy(query, policy_id, principal),
6766            QueryExpr::ShowPolicies { ref filter } => {
6767                self.execute_show_policies(query, filter.as_ref())
6768            }
6769            QueryExpr::ShowEffectivePermissions {
6770                ref user,
6771                ref resource,
6772            } => self.execute_show_effective_permissions(query, user, resource.as_ref()),
6773            QueryExpr::SimulatePolicy {
6774                ref user,
6775                ref action,
6776                ref resource,
6777            } => self.execute_simulate_policy(query, user, action, resource),
6778            QueryExpr::LintPolicy { ref source } => self.execute_lint_policy(query, source),
6779            QueryExpr::MigratePolicyMode {
6780                ref target,
6781                dry_run,
6782            } => self.execute_migrate_policy_mode(query, target, dry_run),
6783            QueryExpr::CreateMigration(ref q) => self.execute_create_migration(query, q),
6784            QueryExpr::ApplyMigration(ref q) => self.execute_apply_migration(query, q),
6785            QueryExpr::RollbackMigration(ref q) => self.execute_rollback_migration(query, q),
6786            QueryExpr::ExplainMigration(ref q) => self.execute_explain_migration(query, q),
6787            _ => Err(RedDBError::Query(
6788                "unsupported command in runtime dispatcher".to_string(),
6789            )),
6790        };
6791
6792        if !control_event_specs.is_empty() {
6793            let (outcome, reason) = match &query_result {
6794                Ok(_) => (crate::runtime::control_events::Outcome::Allowed, None),
6795                Err(err) => (control_event_outcome_for_error(err), Some(err.to_string())),
6796            };
6797            for spec in &control_event_specs {
6798                self.emit_control_event(
6799                    spec.kind,
6800                    outcome,
6801                    spec.action,
6802                    spec.resource.clone(),
6803                    reason.clone(),
6804                    spec.fields.clone(),
6805                )?;
6806            }
6807        }
6808
6809        if let (Some(plan), Ok(result)) = (&query_audit_plan, &query_result) {
6810            self.emit_query_audit(
6811                query,
6812                plan,
6813                query_audit_started.elapsed().as_millis() as u64,
6814                result,
6815            );
6816        }
6817
6818        // Decrypt Value::Secret columns in-place before caching, so
6819        // cached results match the post-decrypt shape and repeat
6820        // queries skip the per-row AES-GCM pass.
6821        let mut query_result = query_result;
6822        if let Ok(ref mut result) = query_result {
6823            if result.statement_type == "select" {
6824                self.apply_secret_decryption(result);
6825            }
6826        }
6827
6828        // Cache SELECT results for 30s.
6829        // Skip: pre-serialized JSON (large clone), and result sets > 5 rows.
6830        // Large multi-row results (range scans, filtered scans) are rarely
6831        // repeated with the same literal values so the cache hit rate is near
6832        // zero while the clone cost (100 records × ~16 fields each) is high.
6833        // Aggregations (1 row) and point lookups (1 row) still benefit.
6834        if let Ok(ref result) = query_result {
6835            frame.write_result_cache(self, result, result_cache_scopes);
6836        }
6837
6838        query_result
6839    }
6840
6841    /// Snapshot of every registered materialized view's runtime
6842    /// state — feeds the `red.materialized_views` virtual table.
6843    /// Issue #583 slice 10.
6844    pub fn materialized_view_metadata(
6845        &self,
6846    ) -> Vec<crate::storage::cache::result::MaterializedViewMetadata> {
6847        // Issue #595 slice 9c — `current_row_count` is now scraped
6848        // live from the backing collection rather than read from the
6849        // cache slot. Mirrors the slice-10 invariant on
6850        // `queue_pending_gauge` in #527: the live store is the source
6851        // of truth, the cache slot only carries last-refresh telemetry
6852        // (timing, error, refresh cadence).
6853        let store = self.inner.db.store();
6854        let mut entries = self.inner.materialized_views.read().metadata();
6855        for entry in &mut entries {
6856            if let Some(manager) = store.get_collection(&entry.name) {
6857                entry.current_row_count = manager.count() as u64;
6858            }
6859        }
6860        entries
6861    }
6862
6863    /// Drive scheduled refreshes for materialized views with a
6864    /// `REFRESH EVERY <duration>` clause. Called from the background
6865    /// scheduler thread (and from unit tests with a fake clock via
6866    /// `claim_due_at`). Each invocation atomically claims the set of
6867    /// due views (so two concurrent ticks never double-fire the same
6868    /// view) and runs each refresh through the standard execution
6869    /// path — failures are captured in `last_error` and the prior
6870    /// content stays intact. Issue #583 slice 10.
6871    /// Snapshot of every tracked retention sweeper state — feeds the
6872    /// three extra columns on `red.retention`. Issue #584 slice 12.
6873    pub(crate) fn retention_sweeper_snapshot(
6874        &self,
6875    ) -> Vec<(String, crate::runtime::retention_sweeper::SweeperState)> {
6876        self.inner.retention_sweeper.read().snapshot()
6877    }
6878
6879    /// Drive one tick of the retention sweeper. Iterates collections
6880    /// with a retention policy set, physically deletes at most
6881    /// `batch_size` expired rows per collection, and records the
6882    /// `last_sweep_at_ms` / `rows_swept_total` / pending estimate that
6883    /// `red.retention` exposes. Called from the background sweeper
6884    /// thread; safe to invoke directly from tests with a small batch
6885    /// size to drain rows deterministically. Issue #584 slice 12.
6886    ///
6887    /// Deletes are issued as `DELETE FROM <collection> WHERE
6888    /// <ts_column> < <cutoff>` through the standard `execute_query`
6889    /// chokepoint so WAL participation and snapshot guards apply
6890    /// exactly as for a user-issued DELETE — replicas replay the
6891    /// sweeper's deletes via the same WAL stream with no special
6892    /// handling on the replication side.
6893    ///
6894    /// Batching is enforced by tightening the cutoff: if more than
6895    /// `batch_size` rows are expired, the cutoff is dropped to the
6896    /// `batch_size`-th oldest expired timestamp + 1 so the predicate
6897    /// matches roughly `batch_size` rows; the remainder is reported
6898    /// as `current_rows_pending_sweep_estimate` and drained on the
6899    /// next tick.
6900    pub fn sweep_retention_tick(&self, batch_size: usize) {
6901        if batch_size == 0 {
6902            return;
6903        }
6904        let now_ms = std::time::SystemTime::now()
6905            .duration_since(std::time::UNIX_EPOCH)
6906            .map(|d| d.as_millis() as u64)
6907            .unwrap_or(0);
6908
6909        let store = self.inner.db.store();
6910        let collections = store.list_collections();
6911        for name in collections {
6912            let Some(contract) = self.inner.db.collection_contract(&name) else {
6913                continue;
6914            };
6915            let Some(retention_ms) = contract.retention_duration_ms else {
6916                continue;
6917            };
6918            let Some(ts_column) =
6919                crate::runtime::retention_filter::resolve_timestamp_column(&contract)
6920            else {
6921                continue;
6922            };
6923            let Some(manager) = store.get_collection(&name) else {
6924                continue;
6925            };
6926            let cutoff = (now_ms as i64).saturating_sub(retention_ms as i64);
6927
6928            // Single pass: collect expired timestamps. We keep the
6929            // full Vec rather than a bounded heap because the partial
6930            // sort below is the simplest correct way to find the
6931            // batch-th oldest; for the slice's "1000-row default
6932            // batch" target this is bounded enough for production
6933            // operation, and the alternative (in-place heap of size
6934            // batch+1) is a follow-up optimisation.
6935            let mut expired_ts: Vec<i64> = Vec::new();
6936            manager.for_each_entity(|entity| {
6937                let ts = match ts_column.as_str() {
6938                    "created_at" => Some(entity.created_at as i64),
6939                    "updated_at" => Some(entity.updated_at as i64),
6940                    other => entity
6941                        .data
6942                        .as_row()
6943                        .and_then(|row| row.get_field(other))
6944                        .and_then(|v| match v {
6945                            crate::storage::schema::Value::TimestampMs(t) => Some(*t),
6946                            crate::storage::schema::Value::Timestamp(t) => {
6947                                Some(t.saturating_mul(1_000))
6948                            }
6949                            crate::storage::schema::Value::BigInt(t) => Some(*t),
6950                            crate::storage::schema::Value::UnsignedInteger(t) => {
6951                                i64::try_from(*t).ok()
6952                            }
6953                            crate::storage::schema::Value::Integer(t) => Some(*t),
6954                            _ => None,
6955                        }),
6956                };
6957                if let Some(t) = ts {
6958                    if t < cutoff {
6959                        expired_ts.push(t);
6960                    }
6961                }
6962                true
6963            });
6964
6965            let total_expired = expired_ts.len() as u64;
6966            if total_expired == 0 {
6967                self.inner
6968                    .retention_sweeper
6969                    .write()
6970                    .record_tick(&name, 0, 0, now_ms);
6971                continue;
6972            }
6973
6974            let (effective_cutoff, pending) = if (total_expired as usize) <= batch_size {
6975                (cutoff, 0u64)
6976            } else {
6977                // Tighten the cutoff to the (batch_size)-th oldest
6978                // expired timestamp + 1 so DELETE matches roughly
6979                // `batch_size` rows.
6980                expired_ts.sort_unstable();
6981                let nth = expired_ts[batch_size - 1];
6982                (
6983                    nth.saturating_add(1),
6984                    total_expired.saturating_sub(batch_size as u64),
6985                )
6986            };
6987
6988            let stmt = format!(
6989                "DELETE FROM {} WHERE {} < {}",
6990                name, ts_column, effective_cutoff
6991            );
6992            let deleted = match self.execute_query(&stmt) {
6993                Ok(r) => r.affected_rows,
6994                Err(_) => 0,
6995            };
6996
6997            self.inner
6998                .retention_sweeper
6999                .write()
7000                .record_tick(&name, deleted, pending, now_ms);
7001        }
7002    }
7003
7004    pub fn refresh_due_materialized_views(&self) {
7005        let due = {
7006            let mut cache = self.inner.materialized_views.write();
7007            cache.claim_due_at(std::time::Instant::now())
7008        };
7009        for name in due {
7010            // Round-trip through `execute_query` (rather than the
7011            // prepared-statement `execute_query_expr` fast path, which
7012            // explicitly rejects DDL/maintenance statements). Failures
7013            // are captured inside the RefreshMaterializedView handler
7014            // via `record_refresh_failure`; the scheduler ignores the
7015            // Result so one bad view doesn't halt the loop.
7016            let stmt = format!("REFRESH MATERIALIZED VIEW {}", name);
7017            let _ = self.execute_query(&stmt);
7018        }
7019    }
7020
7021    /// Execute a pre-parsed `QueryExpr` directly, bypassing SQL parsing and the
7022    /// plan cache. Used by the prepared-statement fast path so that `execute_prepared`
7023    /// calls pay zero parse + cache overhead.
7024    ///
7025    /// Applies secret decryption on SELECT results, identical to `execute_query`.
7026    pub fn execute_query_expr(&self, expr: QueryExpr) -> RedDBResult<RuntimeQueryResult> {
7027        let _config_snapshot_guard = ConfigSnapshotGuard::install(Arc::clone(&self.inner.db));
7028        let _secret_store_guard = SecretStoreGuard::install(self.inner.auth_store.read().clone());
7029        // View rewrite (Phase 2.1): substitute any `QueryExpr::Table(tq)`
7030        // whose `tq.table` matches a registered view with the view's
7031        // underlying query. Safe to call even when no views are registered.
7032        let expr = self.rewrite_view_refs(expr);
7033
7034        self.validate_model_operations_before_auth(&expr)?;
7035        // Granular RBAC privilege check. Runs before dispatch so a
7036        // denied caller never reaches storage. Fail-closed: any error
7037        // resolving the action / resource produces PermissionDenied.
7038        if let Err(err) = self.check_query_privilege(&expr) {
7039            return Err(RedDBError::Query(format!("permission denied: {err}")));
7040        }
7041
7042        let statement = query_expr_name(&expr);
7043        let mode = detect_mode(statement);
7044        let query_str = statement;
7045
7046        let result = self.dispatch_expr(expr, query_str, mode)?;
7047        let mut r = result;
7048        if r.statement_type == "select" {
7049            self.apply_secret_decryption(&mut r);
7050        }
7051        Ok(r)
7052    }
7053
7054    pub(super) fn validate_model_operations_before_auth(
7055        &self,
7056        expr: &QueryExpr,
7057    ) -> RedDBResult<()> {
7058        use crate::catalog::CollectionModel;
7059        use crate::runtime::ddl::polymorphic_resolver;
7060        use crate::storage::query::ast::KvCommand;
7061
7062        let system_schema_target = match expr {
7063            QueryExpr::DropTable(q) => Some(q.name.as_str()),
7064            QueryExpr::DropGraph(q) => Some(q.name.as_str()),
7065            QueryExpr::DropVector(q) => Some(q.name.as_str()),
7066            QueryExpr::DropDocument(q) => Some(q.name.as_str()),
7067            QueryExpr::DropKv(q) => Some(q.name.as_str()),
7068            QueryExpr::DropCollection(q) => Some(q.name.as_str()),
7069            QueryExpr::Truncate(q) => Some(q.name.as_str()),
7070            _ => None,
7071        };
7072        if system_schema_target.is_some_and(crate::runtime::impl_ddl::is_system_schema_name) {
7073            return Err(RedDBError::Query("system schema is read-only".to_string()));
7074        }
7075
7076        let expected = match expr {
7077            QueryExpr::DropTable(q) => Some((q.name.as_str(), CollectionModel::Table)),
7078            QueryExpr::DropGraph(q) => Some((q.name.as_str(), CollectionModel::Graph)),
7079            QueryExpr::DropVector(q) => Some((q.name.as_str(), CollectionModel::Vector)),
7080            QueryExpr::DropDocument(q) => Some((q.name.as_str(), CollectionModel::Document)),
7081            QueryExpr::DropKv(q) => Some((q.name.as_str(), q.model)),
7082            QueryExpr::DropCollection(q) => q.model.map(|model| (q.name.as_str(), model)),
7083            QueryExpr::Truncate(q) => q.model.map(|model| (q.name.as_str(), model)),
7084            QueryExpr::KvCommand(cmd) => {
7085                let (collection, model) = match cmd {
7086                    KvCommand::Put {
7087                        collection, model, ..
7088                    }
7089                    | KvCommand::Get {
7090                        collection, model, ..
7091                    }
7092                    | KvCommand::Incr {
7093                        collection, model, ..
7094                    }
7095                    | KvCommand::Cas {
7096                        collection, model, ..
7097                    }
7098                    | KvCommand::List {
7099                        collection, model, ..
7100                    }
7101                    | KvCommand::Delete {
7102                        collection, model, ..
7103                    } => (collection.as_str(), *model),
7104                    KvCommand::Rotate { collection, .. }
7105                    | KvCommand::History { collection, .. }
7106                    | KvCommand::Purge { collection, .. } => {
7107                        (collection.as_str(), CollectionModel::Vault)
7108                    }
7109                    KvCommand::InvalidateTags { collection, .. } => {
7110                        (collection.as_str(), CollectionModel::Kv)
7111                    }
7112                    KvCommand::Watch {
7113                        collection, model, ..
7114                    } => (collection.as_str(), *model),
7115                    KvCommand::Unseal { collection, .. } => {
7116                        (collection.as_str(), CollectionModel::Vault)
7117                    }
7118                };
7119                Some((collection, model))
7120            }
7121            QueryExpr::ConfigCommand(cmd) => {
7122                self.validate_config_command_before_auth(cmd)?;
7123                None
7124            }
7125            _ => None,
7126        };
7127
7128        let Some((name, expected_model)) = expected else {
7129            return Ok(());
7130        };
7131        let snapshot = self.inner.db.catalog_model_snapshot();
7132        let Some(actual_model) = snapshot
7133            .collections
7134            .iter()
7135            .find(|collection| collection.name == name)
7136            .map(|collection| collection.declared_model.unwrap_or(collection.model))
7137        else {
7138            return Ok(());
7139        };
7140        polymorphic_resolver::ensure_model_match(expected_model, actual_model)
7141    }
7142
7143    /// Walk a `QueryExpr` and replace `QueryExpr::Table(tq)` nodes whose
7144    /// `tq.table` matches a registered view name with the view's stored
7145    /// body. Recurses through joins so `SELECT ... FROM t JOIN myview ...`
7146    /// resolves correctly. Pure operation — no side effects.
7147    pub(super) fn rewrite_view_refs(&self, expr: QueryExpr) -> QueryExpr {
7148        // Fast path: no views registered → return original expression.
7149        if self.inner.views.read().is_empty() {
7150            return expr;
7151        }
7152        self.rewrite_view_refs_inner(expr)
7153    }
7154
7155    fn rewrite_view_refs_inner(&self, expr: QueryExpr) -> QueryExpr {
7156        use crate::storage::query::ast::{Filter, TableSource};
7157        match expr {
7158            QueryExpr::Table(mut tq) => {
7159                // 1. If the TableSource is a subquery, recurse into it so
7160                //    `SELECT ... FROM (SELECT ... FROM myview) t` expands.
7161                //    The legacy `table` field (set to a synthetic
7162                //    "__subq_NNNN" sentinel) stays as-is so callers that
7163                //    read it keep compiling.
7164                if let Some(TableSource::Subquery(body)) = tq.source.take() {
7165                    tq.source = Some(TableSource::Subquery(Box::new(
7166                        self.rewrite_view_refs_inner(*body),
7167                    )));
7168                    return QueryExpr::Table(tq);
7169                }
7170
7171                // 2. Restore the source field (took it above for match).
7172                // When the source was `None` or `TableSource::Name(_)`, the
7173                // real lookup key is `tq.table` — check the view registry.
7174                let maybe_view = {
7175                    let views = self.inner.views.read();
7176                    views.get(&tq.table).cloned()
7177                };
7178                let Some(view) = maybe_view else {
7179                    return QueryExpr::Table(tq);
7180                };
7181
7182                // Issue #594 slice 9b — materialized views are read
7183                // from their backing collection, not by substituting
7184                // the body. Returning the TableQuery as-is lets the
7185                // normal table-read path resolve `SELECT FROM v`
7186                // against the collection provisioned at CREATE time.
7187                if view.materialized {
7188                    return QueryExpr::Table(tq);
7189                }
7190
7191                // Recurse into the view body — views may reference other
7192                // views. The recursion yields the final QueryExpr we need
7193                // to merge the outer's filter / limit / offset into.
7194                let inner_expr = self.rewrite_view_refs_inner((*view.query).clone());
7195
7196                // Phase 5: when the body is a Table we merge the outer
7197                // TableQuery's WHERE / LIMIT / OFFSET into it so stacked
7198                // views filter recursively. Non-table bodies (Search,
7199                // Ask, Vector, Graph, Hybrid) can't meaningfully combine
7200                // with an outer Table query today — return the body
7201                // verbatim; outer predicates are lost. Full projection
7202                // merge lands in Phase 5.2.
7203                match inner_expr {
7204                    QueryExpr::Table(mut inner_tq) => {
7205                        if let Some(outer_filter) = tq.filter.take() {
7206                            inner_tq.filter = Some(match inner_tq.filter.take() {
7207                                Some(existing) => {
7208                                    Filter::And(Box::new(existing), Box::new(outer_filter))
7209                                }
7210                                None => outer_filter,
7211                            });
7212                            // Keep the `Expr` form in lock-step with the
7213                            // merged `Filter`. The executor prefers
7214                            // `where_expr` and nulls `filter` when it is
7215                            // present (see `execute_query_inner`), so a
7216                            // stacked view whose outer predicate was only
7217                            // merged into `filter` would silently drop that
7218                            // predicate at eval time (#635).
7219                            inner_tq.where_expr = inner_tq
7220                                .filter
7221                                .as_ref()
7222                                .map(crate::storage::query::sql_lowering::filter_to_expr);
7223                        }
7224                        if let Some(outer_limit) = tq.limit {
7225                            inner_tq.limit = Some(match inner_tq.limit {
7226                                Some(existing) => existing.min(outer_limit),
7227                                None => outer_limit,
7228                            });
7229                        }
7230                        if let Some(outer_offset) = tq.offset {
7231                            inner_tq.offset = Some(match inner_tq.offset {
7232                                Some(existing) => existing + outer_offset,
7233                                None => outer_offset,
7234                            });
7235                        }
7236                        QueryExpr::Table(inner_tq)
7237                    }
7238                    other => other,
7239                }
7240            }
7241            QueryExpr::Join(mut jq) => {
7242                jq.left = Box::new(self.rewrite_view_refs_inner(*jq.left));
7243                jq.right = Box::new(self.rewrite_view_refs_inner(*jq.right));
7244                QueryExpr::Join(jq)
7245            }
7246            // Other variants don't carry nested QueryExpr that can reference
7247            // a view by table name. Return as-is.
7248            other => other,
7249        }
7250    }
7251
7252    /// Apply table-level read authorization and RLS rewriting for a
7253    /// relational SELECT leaf.
7254    fn authorize_relational_table_select(
7255        &self,
7256        mut table: TableQuery,
7257        frame: &dyn super::statement_frame::ReadFrame,
7258    ) -> RedDBResult<Option<TableQuery>> {
7259        if let Some(TableSource::Subquery(inner)) = table.source.take() {
7260            let authorized_inner = self.authorize_relational_select_expr(*inner, frame)?;
7261            table.source = Some(TableSource::Subquery(Box::new(authorized_inner)));
7262            return Ok(Some(table));
7263        }
7264
7265        self.check_table_column_projection_authz(&table, frame)?;
7266
7267        if self.inner.rls_enabled_tables.read().contains(&table.table) {
7268            return Ok(inject_rls_filters(self, frame, table));
7269        }
7270
7271        Ok(Some(table))
7272    }
7273
7274    fn authorize_relational_join_select(
7275        &self,
7276        mut join: JoinQuery,
7277        frame: &dyn super::statement_frame::ReadFrame,
7278    ) -> RedDBResult<Option<JoinQuery>> {
7279        self.check_join_column_projection_authz(&join, frame)?;
7280        join.left = Box::new(self.authorize_relational_join_child(*join.left, frame)?);
7281        join.right = Box::new(self.authorize_relational_join_child(*join.right, frame)?);
7282        Ok(inject_rls_into_join(self, frame, join))
7283    }
7284
7285    fn authorize_relational_join_child(
7286        &self,
7287        expr: QueryExpr,
7288        frame: &dyn super::statement_frame::ReadFrame,
7289    ) -> RedDBResult<QueryExpr> {
7290        match expr {
7291            QueryExpr::Table(mut table) => {
7292                if let Some(TableSource::Subquery(inner)) = table.source.take() {
7293                    let authorized_inner = self.authorize_relational_select_expr(*inner, frame)?;
7294                    table.source = Some(TableSource::Subquery(Box::new(authorized_inner)));
7295                }
7296                Ok(QueryExpr::Table(table))
7297            }
7298            QueryExpr::Join(join) => self
7299                .authorize_relational_join_select(join, frame)?
7300                .map(QueryExpr::Join)
7301                .ok_or_else(|| {
7302                    RedDBError::Query("permission denied: RLS denied relational subquery".into())
7303                }),
7304            other => Ok(other),
7305        }
7306    }
7307
7308    fn authorize_relational_select_expr(
7309        &self,
7310        expr: QueryExpr,
7311        frame: &dyn super::statement_frame::ReadFrame,
7312    ) -> RedDBResult<QueryExpr> {
7313        match expr {
7314            QueryExpr::Table(table) => self
7315                .authorize_relational_table_select(table, frame)?
7316                .map(QueryExpr::Table)
7317                .ok_or_else(|| {
7318                    RedDBError::Query("permission denied: RLS denied relational subquery".into())
7319                }),
7320            QueryExpr::Join(join) => self
7321                .authorize_relational_join_select(join, frame)?
7322                .map(QueryExpr::Join)
7323                .ok_or_else(|| {
7324                    RedDBError::Query("permission denied: RLS denied relational subquery".into())
7325                }),
7326            other => Ok(other),
7327        }
7328    }
7329
7330    fn check_table_column_projection_authz(
7331        &self,
7332        table: &TableQuery,
7333        frame: &dyn super::statement_frame::ReadFrame,
7334    ) -> RedDBResult<()> {
7335        let Some((username, role)) = frame.identity() else {
7336            return Ok(());
7337        };
7338        let Some(auth_store) = self.inner.auth_store.read().clone() else {
7339            return Ok(());
7340        };
7341
7342        let columns = self.resolved_table_projection_columns(table)?;
7343        let request = ColumnAccessRequest::select(table.table.clone(), columns);
7344        let principal = UserId::from_parts(frame.effective_scope(), username);
7345        let ctx = runtime_iam_context(role, frame.effective_scope());
7346        let outcome = auth_store.check_column_projection_authz(&principal, &request, &ctx);
7347        if outcome.allowed() {
7348            return Ok(());
7349        }
7350
7351        if let Some(denied) = outcome.first_denied_column() {
7352            return Err(RedDBError::Query(format!(
7353                "permission denied: principal=`{username}` cannot select column `{}`",
7354                denied.resource.name
7355            )));
7356        }
7357        Err(RedDBError::Query(format!(
7358            "permission denied: principal=`{username}` cannot select table `{}`",
7359            table.table
7360        )))
7361    }
7362
7363    fn check_join_column_projection_authz(
7364        &self,
7365        join: &JoinQuery,
7366        frame: &dyn super::statement_frame::ReadFrame,
7367    ) -> RedDBResult<()> {
7368        let mut by_table: HashMap<String, BTreeSet<String>> = HashMap::new();
7369        let projections = crate::storage::query::sql_lowering::effective_join_projections(join);
7370        self.collect_join_projection_columns(join, &projections, &mut by_table)?;
7371
7372        for (table, columns) in by_table {
7373            let query = TableQuery {
7374                table,
7375                source: None,
7376                alias: None,
7377                select_items: Vec::new(),
7378                columns: columns.into_iter().map(Projection::Column).collect(),
7379                where_expr: None,
7380                filter: None,
7381                group_by_exprs: Vec::new(),
7382                group_by: Vec::new(),
7383                having_expr: None,
7384                having: None,
7385                order_by: Vec::new(),
7386                limit: None,
7387                limit_param: None,
7388                offset: None,
7389                offset_param: None,
7390                expand: None,
7391                as_of: None,
7392                sessionize: None,
7393                distinct: false,
7394            };
7395            self.check_table_column_projection_authz(&query, frame)?;
7396        }
7397        Ok(())
7398    }
7399
7400    fn collect_join_projection_columns(
7401        &self,
7402        join: &JoinQuery,
7403        projections: &[Projection],
7404        out: &mut HashMap<String, BTreeSet<String>>,
7405    ) -> RedDBResult<()> {
7406        let left = table_side_context(join.left.as_ref());
7407        let right = table_side_context(join.right.as_ref());
7408
7409        if projections
7410            .iter()
7411            .any(|projection| matches!(projection, Projection::All))
7412        {
7413            for side in [left.as_ref(), right.as_ref()].into_iter().flatten() {
7414                out.entry(side.table.clone())
7415                    .or_default()
7416                    .extend(self.table_all_projection_columns(&side.table)?);
7417            }
7418            return Ok(());
7419        }
7420
7421        for projection in projections {
7422            collect_projection_columns_for_join_side(
7423                projection,
7424                left.as_ref(),
7425                right.as_ref(),
7426                out,
7427            )?;
7428        }
7429        Ok(())
7430    }
7431
7432    fn resolved_table_projection_columns(&self, table: &TableQuery) -> RedDBResult<Vec<String>> {
7433        let projections = crate::storage::query::sql_lowering::effective_table_projections(table);
7434        if projections
7435            .iter()
7436            .any(|projection| matches!(projection, Projection::All))
7437        {
7438            return self.table_all_projection_columns(&table.table);
7439        }
7440
7441        let mut columns = BTreeSet::new();
7442        for projection in &projections {
7443            collect_projection_columns_for_table(
7444                projection,
7445                &table.table,
7446                table.alias.as_deref(),
7447                &mut columns,
7448            );
7449        }
7450        Ok(columns.into_iter().collect())
7451    }
7452
7453    fn table_all_projection_columns(&self, table: &str) -> RedDBResult<Vec<String>> {
7454        if let Some(contract) = self.inner.db.collection_contract_arc(table) {
7455            let columns: Vec<String> = contract
7456                .declared_columns
7457                .iter()
7458                .map(|column| column.name.clone())
7459                .collect();
7460            if !columns.is_empty() {
7461                return Ok(columns);
7462            }
7463        }
7464
7465        let records = scan_runtime_table_source_records_limited(&self.inner.db, table, Some(1))?;
7466        Ok(records
7467            .first()
7468            .map(|record| {
7469                record
7470                    .column_names()
7471                    .into_iter()
7472                    .map(|column| column.to_string())
7473                    .collect()
7474            })
7475            .unwrap_or_default())
7476    }
7477
7478    fn resolve_table_expr_subqueries(
7479        &self,
7480        mut table: TableQuery,
7481        frame: &dyn super::statement_frame::ReadFrame,
7482    ) -> RedDBResult<TableQuery> {
7483        // Only a `Subquery` source needs recursive resolution. `.take()`
7484        // would otherwise drop a `Name` / `Function` source on the floor
7485        // (the `if let` skips the body but the take already cleared it),
7486        // which silently broke `SELECT * FROM components(g)` — the TVF
7487        // dispatch downstream keys off `TableSource::Function` and never
7488        // fired. Restore any non-subquery source unchanged (issue #795).
7489        match table.source.take() {
7490            Some(TableSource::Subquery(inner)) => {
7491                let inner = self.resolve_select_expr_subqueries(*inner, frame)?;
7492                table.source = Some(TableSource::Subquery(Box::new(inner)));
7493            }
7494            other => table.source = other,
7495        }
7496
7497        let outer_scopes = relation_scopes_for_query(&QueryExpr::Table(table.clone()));
7498        for item in &mut table.select_items {
7499            if let crate::storage::query::ast::SelectItem::Expr { expr, .. } = item {
7500                *expr = self.resolve_expr_subqueries(expr.clone(), &outer_scopes, frame)?;
7501            }
7502        }
7503        if let Some(where_expr) = table.where_expr.take() {
7504            table.where_expr =
7505                Some(self.resolve_expr_subqueries(where_expr, &outer_scopes, frame)?);
7506            table.filter = None;
7507        }
7508        if let Some(having_expr) = table.having_expr.take() {
7509            table.having_expr =
7510                Some(self.resolve_expr_subqueries(having_expr, &outer_scopes, frame)?);
7511            table.having = None;
7512        }
7513        for expr in &mut table.group_by_exprs {
7514            *expr = self.resolve_expr_subqueries(expr.clone(), &outer_scopes, frame)?;
7515        }
7516        for clause in &mut table.order_by {
7517            if let Some(expr) = clause.expr.take() {
7518                clause.expr = Some(self.resolve_expr_subqueries(expr, &outer_scopes, frame)?);
7519            }
7520        }
7521        Ok(table)
7522    }
7523
7524    fn resolve_select_expr_subqueries(
7525        &self,
7526        expr: QueryExpr,
7527        frame: &dyn super::statement_frame::ReadFrame,
7528    ) -> RedDBResult<QueryExpr> {
7529        match expr {
7530            QueryExpr::Table(table) => self
7531                .resolve_table_expr_subqueries(table, frame)
7532                .map(QueryExpr::Table),
7533            QueryExpr::Join(mut join) => {
7534                join.left = Box::new(self.resolve_select_expr_subqueries(*join.left, frame)?);
7535                join.right = Box::new(self.resolve_select_expr_subqueries(*join.right, frame)?);
7536                Ok(QueryExpr::Join(join))
7537            }
7538            other => Ok(other),
7539        }
7540    }
7541
7542    fn resolve_expr_subqueries(
7543        &self,
7544        expr: crate::storage::query::ast::Expr,
7545        outer_scopes: &[String],
7546        frame: &dyn super::statement_frame::ReadFrame,
7547    ) -> RedDBResult<crate::storage::query::ast::Expr> {
7548        use crate::storage::query::ast::Expr;
7549
7550        match expr {
7551            Expr::Subquery { query, span } => {
7552                let values = self.execute_expr_subquery_values(query, outer_scopes, frame)?;
7553                if values.len() > 1 {
7554                    return Err(RedDBError::Query(
7555                        "scalar subquery returned more than one row".to_string(),
7556                    ));
7557                }
7558                Ok(Expr::Literal {
7559                    value: values.into_iter().next().unwrap_or(Value::Null),
7560                    span,
7561                })
7562            }
7563            Expr::BinaryOp { op, lhs, rhs, span } => Ok(Expr::BinaryOp {
7564                op,
7565                lhs: Box::new(self.resolve_expr_subqueries(*lhs, outer_scopes, frame)?),
7566                rhs: Box::new(self.resolve_expr_subqueries(*rhs, outer_scopes, frame)?),
7567                span,
7568            }),
7569            Expr::UnaryOp { op, operand, span } => Ok(Expr::UnaryOp {
7570                op,
7571                operand: Box::new(self.resolve_expr_subqueries(*operand, outer_scopes, frame)?),
7572                span,
7573            }),
7574            Expr::Cast {
7575                inner,
7576                target,
7577                span,
7578            } => Ok(Expr::Cast {
7579                inner: Box::new(self.resolve_expr_subqueries(*inner, outer_scopes, frame)?),
7580                target,
7581                span,
7582            }),
7583            Expr::FunctionCall { name, args, span } => {
7584                let args = args
7585                    .into_iter()
7586                    .map(|arg| self.resolve_expr_subqueries(arg, outer_scopes, frame))
7587                    .collect::<RedDBResult<Vec<_>>>()?;
7588                Ok(Expr::FunctionCall { name, args, span })
7589            }
7590            Expr::Case {
7591                branches,
7592                else_,
7593                span,
7594            } => {
7595                let branches = branches
7596                    .into_iter()
7597                    .map(|(cond, value)| {
7598                        Ok((
7599                            self.resolve_expr_subqueries(cond, outer_scopes, frame)?,
7600                            self.resolve_expr_subqueries(value, outer_scopes, frame)?,
7601                        ))
7602                    })
7603                    .collect::<RedDBResult<Vec<_>>>()?;
7604                let else_ = else_
7605                    .map(|expr| self.resolve_expr_subqueries(*expr, outer_scopes, frame))
7606                    .transpose()?
7607                    .map(Box::new);
7608                Ok(Expr::Case {
7609                    branches,
7610                    else_,
7611                    span,
7612                })
7613            }
7614            Expr::IsNull {
7615                operand,
7616                negated,
7617                span,
7618            } => Ok(Expr::IsNull {
7619                operand: Box::new(self.resolve_expr_subqueries(*operand, outer_scopes, frame)?),
7620                negated,
7621                span,
7622            }),
7623            Expr::InList {
7624                target,
7625                values,
7626                negated,
7627                span,
7628            } => {
7629                let target =
7630                    Box::new(self.resolve_expr_subqueries(*target, outer_scopes, frame)?);
7631                let mut resolved = Vec::new();
7632                for value in values {
7633                    if let Expr::Subquery { query, .. } = value {
7634                        resolved.extend(
7635                            self.execute_expr_subquery_values(query, outer_scopes, frame)?
7636                                .into_iter()
7637                                .map(Expr::lit),
7638                        );
7639                    } else {
7640                        resolved.push(self.resolve_expr_subqueries(value, outer_scopes, frame)?);
7641                    }
7642                }
7643                Ok(Expr::InList {
7644                    target,
7645                    values: resolved,
7646                    negated,
7647                    span,
7648                })
7649            }
7650            Expr::Between {
7651                target,
7652                low,
7653                high,
7654                negated,
7655                span,
7656            } => Ok(Expr::Between {
7657                target: Box::new(self.resolve_expr_subqueries(*target, outer_scopes, frame)?),
7658                low: Box::new(self.resolve_expr_subqueries(*low, outer_scopes, frame)?),
7659                high: Box::new(self.resolve_expr_subqueries(*high, outer_scopes, frame)?),
7660                negated,
7661                span,
7662            }),
7663            other => Ok(other),
7664        }
7665    }
7666
7667    fn execute_expr_subquery_values(
7668        &self,
7669        subquery: crate::storage::query::ast::ExprSubquery,
7670        outer_scopes: &[String],
7671        frame: &dyn super::statement_frame::ReadFrame,
7672    ) -> RedDBResult<Vec<Value>> {
7673        let query = *subquery.query;
7674        if query_references_outer_scope(&query, outer_scopes) {
7675            return Err(RedDBError::Query(
7676                "NOT_YET_SUPPORTED: correlated subqueries are not supported yet; track follow-up issue #470-correlated-subqueries".to_string(),
7677            ));
7678        }
7679        let query = self.rewrite_view_refs(query);
7680        let query = self.resolve_select_expr_subqueries(query, frame)?;
7681        let query = self.authorize_relational_select_expr(query, frame)?;
7682        let result = match query {
7683            QueryExpr::Table(table) => {
7684                execute_runtime_table_query(&self.inner.db, &table, Some(&self.inner.index_store))?
7685            }
7686            QueryExpr::Join(join) => execute_runtime_join_query(&self.inner.db, &join)?,
7687            other => {
7688                return Err(RedDBError::Query(format!(
7689                    "expression subquery must be a SELECT query, got {}",
7690                    query_expr_name(&other)
7691                )))
7692            }
7693        };
7694        first_column_values(result)
7695    }
7696
7697    fn dispatch_expr(
7698        &self,
7699        expr: QueryExpr,
7700        query_str: &str,
7701        mode: QueryMode,
7702    ) -> RedDBResult<RuntimeQueryResult> {
7703        let statement = query_expr_name(&expr);
7704        match expr {
7705            QueryExpr::Graph(_) | QueryExpr::Path(_) => {
7706                // Graph queries are not cacheable as prepared statements.
7707                Err(RedDBError::Query(
7708                    "graph queries cannot be used as prepared statements".to_string(),
7709                ))
7710            }
7711            QueryExpr::Table(table) => {
7712                let scope = self.ai_scope();
7713                let table = self.resolve_table_expr_subqueries(
7714                    table,
7715                    &scope as &dyn super::statement_frame::ReadFrame,
7716                )?;
7717                // Table-valued functions (e.g. components(g)) dispatch to a
7718                // read-only executor before any catalog/virtual-table routing
7719                // (issue #795).
7720                if let Some(TableSource::Function {
7721                    name,
7722                    args,
7723                    named_args,
7724                }) = table.source.clone()
7725                {
7726                    return Ok(RuntimeQueryResult {
7727                        query: query_str.to_string(),
7728                        mode,
7729                        statement,
7730                        engine: "runtime-graph-tvf",
7731                        result: self.execute_table_function(&name, &args, &named_args)?,
7732                        affected_rows: 0,
7733                        statement_type: "select",
7734                        bookmark: None,
7735                    });
7736                }
7737                // Inline-graph TVF (issue #799) on the prepared-statement /
7738                // direct-expr path. Result caching is wired on the
7739                // `execute_query_inner` path; here we just compute and return.
7740                if let Some(TableSource::InlineGraphFunction {
7741                    name,
7742                    nodes,
7743                    edges,
7744                    named_args,
7745                }) = table.source.clone()
7746                {
7747                    return Ok(RuntimeQueryResult {
7748                        query: query_str.to_string(),
7749                        mode,
7750                        statement,
7751                        engine: "runtime-graph-tvf-inline",
7752                        result: self.execute_inline_graph_function(
7753                            &name,
7754                            &nodes,
7755                            &edges,
7756                            &named_args,
7757                        )?,
7758                        affected_rows: 0,
7759                        statement_type: "select",
7760                        bookmark: None,
7761                    });
7762                }
7763                if super::red_schema::is_virtual_table(&table.table) {
7764                    return Ok(RuntimeQueryResult {
7765                        query: query_str.to_string(),
7766                        mode,
7767                        statement,
7768                        engine: "runtime-red-schema",
7769                        result: super::red_schema::red_query(
7770                            self,
7771                            &table.table,
7772                            &table,
7773                            &scope as &dyn super::statement_frame::ReadFrame,
7774                        )?,
7775                        affected_rows: 0,
7776                        statement_type: "select",
7777                        bookmark: None,
7778                    });
7779                }
7780                // `<graph>.<output>` analytics virtual view (issue #800).
7781                if let Some(view_result) = self.try_resolve_analytics_view(
7782                    &table,
7783                    &scope as &dyn super::statement_frame::ReadFrame,
7784                )? {
7785                    return Ok(RuntimeQueryResult {
7786                        query: query_str.to_string(),
7787                        mode,
7788                        statement,
7789                        engine: "runtime-graph-analytics-view",
7790                        result: view_result,
7791                        affected_rows: 0,
7792                        statement_type: "select",
7793                        bookmark: None,
7794                    });
7795                }
7796                let Some(table_with_rls) = self.authorize_relational_table_select(
7797                    table,
7798                    &scope as &dyn super::statement_frame::ReadFrame,
7799                )?
7800                else {
7801                    return Ok(RuntimeQueryResult {
7802                        query: query_str.to_string(),
7803                        mode,
7804                        statement,
7805                        engine: "runtime-table-rls",
7806                        result: crate::storage::query::unified::UnifiedResult::empty(),
7807                        affected_rows: 0,
7808                        statement_type: "select",
7809                        bookmark: None,
7810                    });
7811                };
7812                Ok(RuntimeQueryResult {
7813                    query: query_str.to_string(),
7814                    mode,
7815                    statement,
7816                    engine: "runtime-table",
7817                    result: execute_runtime_table_query(
7818                        &self.inner.db,
7819                        &table_with_rls,
7820                        Some(&self.inner.index_store),
7821                    )?,
7822                    affected_rows: 0,
7823                    statement_type: "select",
7824                    bookmark: None,
7825                })
7826            }
7827            QueryExpr::Join(join) => {
7828                let scope = self.ai_scope();
7829                let Some(join_with_rls) = self.authorize_relational_join_select(
7830                    join,
7831                    &scope as &dyn super::statement_frame::ReadFrame,
7832                )?
7833                else {
7834                    return Ok(RuntimeQueryResult {
7835                        query: query_str.to_string(),
7836                        mode,
7837                        statement,
7838                        engine: "runtime-join-rls",
7839                        result: crate::storage::query::unified::UnifiedResult::empty(),
7840                        affected_rows: 0,
7841                        statement_type: "select",
7842                        bookmark: None,
7843                    });
7844                };
7845                Ok(RuntimeQueryResult {
7846                    query: query_str.to_string(),
7847                    mode,
7848                    statement,
7849                    engine: "runtime-join",
7850                    result: execute_runtime_join_query(&self.inner.db, &join_with_rls)?,
7851                    affected_rows: 0,
7852                    statement_type: "select",
7853                    bookmark: None,
7854                })
7855            }
7856            QueryExpr::Vector(vector) => Ok(RuntimeQueryResult {
7857                query: query_str.to_string(),
7858                mode,
7859                statement,
7860                engine: "runtime-vector",
7861                result: execute_runtime_vector_query(&self.inner.db, &vector)?,
7862                affected_rows: 0,
7863                statement_type: "select",
7864                bookmark: None,
7865            }),
7866            QueryExpr::Hybrid(hybrid) => Ok(RuntimeQueryResult {
7867                query: query_str.to_string(),
7868                mode,
7869                statement,
7870                engine: "runtime-hybrid",
7871                result: execute_runtime_hybrid_query(&self.inner.db, &hybrid)?,
7872                affected_rows: 0,
7873                statement_type: "select",
7874                bookmark: None,
7875            }),
7876            QueryExpr::Insert(ref insert) if super::red_schema::is_virtual_table(&insert.table) => {
7877                Err(RedDBError::Query(
7878                    super::red_schema::READ_ONLY_ERROR.to_string(),
7879                ))
7880            }
7881            QueryExpr::Update(ref update) if super::red_schema::is_virtual_table(&update.table) => {
7882                Err(RedDBError::Query(
7883                    super::red_schema::READ_ONLY_ERROR.to_string(),
7884                ))
7885            }
7886            QueryExpr::Delete(ref delete) if super::red_schema::is_virtual_table(&delete.table) => {
7887                Err(RedDBError::Query(
7888                    super::red_schema::READ_ONLY_ERROR.to_string(),
7889                ))
7890            }
7891            QueryExpr::Insert(ref insert) => self
7892                .with_deferred_store_wal_for_dml(self.insert_may_emit_events(insert), || {
7893                    self.execute_insert(query_str, insert)
7894                }),
7895            QueryExpr::Update(ref update) => self
7896                .with_deferred_store_wal_for_dml(self.update_may_emit_events(update), || {
7897                    self.execute_update(query_str, update)
7898                }),
7899            QueryExpr::Delete(ref delete) => self
7900                .with_deferred_store_wal_for_dml(self.delete_may_emit_events(delete), || {
7901                    self.execute_delete(query_str, delete)
7902                }),
7903            QueryExpr::SearchCommand(ref cmd) => self.execute_search_command(query_str, cmd),
7904            QueryExpr::Ask(ref ask) => self.execute_ask(query_str, ask),
7905            _ => Err(RedDBError::Query(format!(
7906                "prepared-statement execution does not support {statement} statements"
7907            ))),
7908        }
7909    }
7910
7911    /// Dispatch a graph-collection table-valued function call in FROM
7912    /// position (e.g. `SELECT * FROM components(g)`).
7913    ///
7914    /// Validates the function name and arity here, materializes the whole
7915    /// active graph read-only, then runs the algorithm via the shared
7916    /// `dispatch_graph_algorithm` path. Never mutates the catalog or store.
7917    fn execute_table_function(
7918        &self,
7919        name: &str,
7920        args: &[String],
7921        named_args: &[(String, f64)],
7922    ) -> RedDBResult<crate::storage::query::unified::UnifiedResult> {
7923        if name.eq_ignore_ascii_case("red.diff") {
7924            return self.execute_vcs_diff_tvf(args, named_args);
7925        }
7926        if !is_graph_tvf_name(name) {
7927            return Err(RedDBError::Query(format!("unknown table function: {name}")));
7928        }
7929        // Every graph-collection TVF takes exactly one graph argument.
7930        if args.len() != 1 {
7931            return Err(RedDBError::Query(format!(
7932                "table function '{name}' takes exactly 1 graph argument, got {}",
7933                args.len()
7934            )));
7935        }
7936
7937        // Read-only materialization of the full active graph. Passing `None`
7938        // for the projection uses the full graph store. Like #795/#796, the
7939        // v0 form runs over the whole graph store regardless of the collection
7940        // argument value. Materialization never mutates any store.
7941        let (nodes, edges) = self.materialize_whole_graph_abstract()?;
7942        self.dispatch_graph_algorithm(name, nodes, edges, named_args)
7943    }
7944
7945    fn execute_vcs_diff_tvf(
7946        &self,
7947        args: &[String],
7948        named_args: &[(String, f64)],
7949    ) -> RedDBResult<crate::storage::query::unified::UnifiedResult> {
7950        use crate::application::vcs::{DiffChange, DiffInput};
7951        use crate::storage::query::unified::{UnifiedRecord, UnifiedResult};
7952        use crate::storage::schema::Value;
7953
7954        if !named_args.is_empty() {
7955            return Err(RedDBError::Query(
7956                "red.diff takes only positional commit arguments".to_string(),
7957            ));
7958        }
7959        if args.len() != 2 {
7960            return Err(RedDBError::Query(format!(
7961                "red.diff takes exactly 2 commit arguments, got {}",
7962                args.len()
7963            )));
7964        }
7965
7966        let diff = self.vcs_diff(DiffInput {
7967            from: args[0].clone(),
7968            to: args[1].clone(),
7969            collection: None,
7970            summary_only: false,
7971        })?;
7972        let mut result = UnifiedResult::with_columns(vec![
7973            "from".into(),
7974            "to".into(),
7975            "collection".into(),
7976            "entity_id".into(),
7977            "change".into(),
7978            "before".into(),
7979            "after".into(),
7980        ]);
7981        for entry in diff.entries {
7982            let (change, before, after) = match entry.change {
7983                DiffChange::Added { after } => ("added", Value::Null, json_runtime_value(after)),
7984                DiffChange::Removed { before } => {
7985                    ("removed", json_runtime_value(before), Value::Null)
7986                }
7987                DiffChange::Modified { before, after } => (
7988                    "modified",
7989                    json_runtime_value(before),
7990                    json_runtime_value(after),
7991                ),
7992            };
7993            let mut record = UnifiedRecord::new();
7994            record.set("from", Value::text(diff.from.clone()));
7995            record.set("to", Value::text(diff.to.clone()));
7996            record.set("collection", Value::text(entry.collection));
7997            record.set("entity_id", Value::text(entry.entity_id));
7998            record.set("change", Value::text(change));
7999            record.set("before", before);
8000            record.set("after", after);
8001            result.push(record);
8002        }
8003        Ok(result)
8004    }
8005
8006    /// Dispatch an inline-graph table-valued function call in FROM position
8007    /// (e.g. `SELECT * FROM components(nodes => (…), edges => (…))`, issue
8008    /// #799).
8009    ///
8010    /// Materializes the two subqueries through the normal read path (so RLS,
8011    /// column authz, and MVCC visibility all apply), constructs the abstract
8012    /// graph — the first column of `nodes` is the node id; the first two-or-
8013    /// three columns of `edges` are `(source, target [, weight])` — then runs
8014    /// the same algorithm path used by the graph-collection form. Read-only.
8015    fn execute_inline_graph_function(
8016        &self,
8017        name: &str,
8018        nodes_query: &QueryExpr,
8019        edges_query: &QueryExpr,
8020        named_args: &[(String, f64)],
8021    ) -> RedDBResult<crate::storage::query::unified::UnifiedResult> {
8022        if !is_graph_tvf_name(name) {
8023            return Err(RedDBError::Query(format!("unknown table function: {name}")));
8024        }
8025
8026        let node_result = self.execute_query_expr(nodes_query.clone())?.result;
8027        let nodes = inline_node_ids(name, &node_result)?;
8028
8029        let edge_result = self.execute_query_expr(edges_query.clone())?.result;
8030        let edges = inline_edges(name, &edge_result)?;
8031
8032        self.dispatch_graph_algorithm(name, nodes, edges, named_args)
8033    }
8034
8035    /// Materialize the whole active graph read-only into the abstract
8036    /// `(nodes, edges)` inputs the pure graph algorithms consume.
8037    fn materialize_whole_graph_abstract(
8038        &self,
8039    ) -> RedDBResult<(
8040        Vec<String>,
8041        Vec<(
8042            String,
8043            String,
8044            crate::storage::engine::graph_algorithms::Weight,
8045        )>,
8046    )> {
8047        use crate::storage::engine::graph_algorithms;
8048
8049        let graph = super::graph_dsl::materialize_graph_with_projection(
8050            self.inner.db.store().as_ref(),
8051            None,
8052        )?;
8053        let nodes: Vec<String> = graph.iter_nodes().map(|n| n.id.clone()).collect();
8054        let edges: Vec<(String, String, graph_algorithms::Weight)> = graph
8055            .iter_all_edges()
8056            .into_iter()
8057            .map(|e| (e.source_id, e.target_id, e.weight))
8058            .collect();
8059        Ok((nodes, edges))
8060    }
8061
8062    /// Resolve a `<graph>.<output>` analytics virtual view (issue #800).
8063    ///
8064    /// Returns `Ok(None)` when `table` is not an analytics view — either the
8065    /// name is not dotted, a real collection of that exact name exists (a real
8066    /// collection always wins; no shadowing), the suffix is not a recognised
8067    /// analytics output, or the parent is not a graph. Returns `Ok(Some(_))`
8068    /// with the freshly computed result when it does resolve, and an error when
8069    /// the parent graph exists but the output is not enabled, a declared
8070    /// algorithm is unsupported, or the parent collection's policy denies the
8071    /// read.
8072    ///
8073    /// The view is recomputed on every call (no result-cache write) so it
8074    /// always reflects the current graph data, satisfying the on-demand
8075    /// recompute contract for this slice.
8076    fn try_resolve_analytics_view(
8077        &self,
8078        table: &TableQuery,
8079        frame: &dyn super::statement_frame::ReadFrame,
8080    ) -> RedDBResult<Option<crate::storage::query::unified::UnifiedResult>> {
8081        let full = table.table.as_str();
8082        let Some(dot) = full.rfind('.') else {
8083            return Ok(None);
8084        };
8085        // A real collection literally named `g.communities` always wins.
8086        if self.inner.db.store().get_collection(full).is_some() {
8087            return Ok(None);
8088        }
8089        let graph_name = &full[..dot];
8090        let output_name = &full[dot + 1..];
8091        let Some(output) = crate::catalog::AnalyticsOutput::from_str(output_name) else {
8092            return Ok(None);
8093        };
8094
8095        let contracts = self.inner.db.collection_contracts();
8096        let Some(contract) = contracts.iter().find(|c| c.name == graph_name) else {
8097            return Ok(None);
8098        };
8099        if contract.declared_model != crate::catalog::CollectionModel::Graph {
8100            return Ok(None);
8101        }
8102        let Some(view) = contract
8103            .analytics_config
8104            .iter()
8105            .find(|view| view.output == output)
8106        else {
8107            // The parent graph exists but this output was not declared — a
8108            // clear error beats the misleading "collection not found".
8109            return Err(RedDBError::Query(format!(
8110                "analytics output '{output_name}' is not enabled on graph '{graph_name}'; declare it with WITH ANALYTICS (...)"
8111            )));
8112        };
8113
8114        // Policy inheritance (AC5): route through the parent graph collection's
8115        // read authorization. A policy or RLS rule that denies the parent
8116        // denies its analytics views transitively.
8117        let parent_query = TableQuery::new(graph_name);
8118        if self
8119            .authorize_relational_table_select(parent_query, frame)?
8120            .is_none()
8121        {
8122            return Err(RedDBError::Query(format!(
8123                "permission denied: policy on graph '{graph_name}' denies analytics view '{output_name}'"
8124            )));
8125        }
8126
8127        let (algorithm, named_args) = analytics_view_algorithm(graph_name, view)?;
8128        let (nodes, edges) = self.materialize_whole_graph_abstract()?;
8129        let result = self.dispatch_graph_algorithm(&algorithm, nodes, edges, &named_args)?;
8130        Ok(Some(result))
8131    }
8132
8133    /// Shared algorithm dispatch over abstract `(nodes, edges)` inputs.
8134    ///
8135    /// Both the graph-collection form and the inline-graph form route here so
8136    /// named-argument validation and the projected row shape stay identical
8137    /// across the two signatures (issue #799). Projects each algorithm's
8138    /// native output shape.
8139    fn dispatch_graph_algorithm(
8140        &self,
8141        name: &str,
8142        nodes: Vec<String>,
8143        edges: Vec<(
8144            String,
8145            String,
8146            crate::storage::engine::graph_algorithms::Weight,
8147        )>,
8148        named_args: &[(String, f64)],
8149    ) -> RedDBResult<crate::storage::query::unified::UnifiedResult> {
8150        use crate::storage::engine::graph_algorithms;
8151        use crate::storage::query::unified::UnifiedResult;
8152        use crate::storage::schema::Value;
8153
8154        if name.eq_ignore_ascii_case("components") {
8155            reject_named_args(name, named_args)?;
8156            let assignment = graph_algorithms::connected_components(&nodes, &edges);
8157            let mut result =
8158                UnifiedResult::with_columns(vec!["node_id".into(), "island_id".into()]);
8159            for (node_id, island_id) in assignment {
8160                let mut record = UnifiedRecord::new();
8161                record.set("node_id", Value::text(node_id));
8162                record.set("island_id", Value::Integer(island_id as i64));
8163                result.push(record);
8164            }
8165            return Ok(result);
8166        }
8167
8168        if name.eq_ignore_ascii_case("louvain") {
8169            // The only supported named argument is `resolution` (γ). It
8170            // defaults to 1.0 (classic modularity) and must be a finite,
8171            // strictly positive number — a non-positive (or NaN/inf)
8172            // resolution has no sensible meaning.
8173            let resolution = louvain_resolution(named_args)?;
8174            let assignment = graph_algorithms::louvain(&nodes, &edges, resolution);
8175            let mut result =
8176                UnifiedResult::with_columns(vec!["node_id".into(), "community_id".into()]);
8177            for (node_id, community_id) in assignment {
8178                let mut record = UnifiedRecord::new();
8179                record.set("node_id", Value::text(node_id));
8180                record.set("community_id", Value::Integer(community_id as i64));
8181                result.push(record);
8182            }
8183            return Ok(result);
8184        }
8185
8186        if name.eq_ignore_ascii_case("degree_centrality") {
8187            reject_named_args(name, named_args)?;
8188            let assignment = abstract_degree_centrality(&nodes, &edges);
8189            let mut result = UnifiedResult::with_columns(vec!["node_id".into(), "degree".into()]);
8190            for (node_id, degree) in assignment {
8191                let mut record = UnifiedRecord::new();
8192                record.set("node_id", Value::text(node_id));
8193                record.set("degree", Value::Integer(degree as i64));
8194                result.push(record);
8195            }
8196            return Ok(result);
8197        }
8198
8199        if name.eq_ignore_ascii_case("shortest_path") {
8200            // Scalar named arguments: `src` and `dst` are required node ids,
8201            // `max_hops` is an optional non-negative edge-count cap. Node ids
8202            // in the graph store are integer entity ids rendered as strings, so
8203            // each id arg must be a non-negative whole number; reject anything
8204            // else (fractional, negative, NaN/inf) with a clear message.
8205            let mut src: Option<String> = None;
8206            let mut dst: Option<String> = None;
8207            let mut max_hops: Option<usize> = None;
8208            let as_node_id = |key: &str, value: f64| -> RedDBResult<String> {
8209                if !value.is_finite() || value < 0.0 || value.fract() != 0.0 {
8210                    return Err(RedDBError::Query(format!(
8211                        "table function 'shortest_path' argument '{key}' must be a non-negative integer node id, got {value}"
8212                    )));
8213                }
8214                Ok((value as i64).to_string())
8215            };
8216            for (key, value) in named_args {
8217                if key.eq_ignore_ascii_case("src") {
8218                    src = Some(as_node_id("src", *value)?);
8219                } else if key.eq_ignore_ascii_case("dst") {
8220                    dst = Some(as_node_id("dst", *value)?);
8221                } else if key.eq_ignore_ascii_case("max_hops") {
8222                    if !value.is_finite() || *value < 0.0 || value.fract() != 0.0 {
8223                        return Err(RedDBError::Query(format!(
8224                            "table function 'shortest_path' max_hops must be a non-negative integer, got {value}"
8225                        )));
8226                    }
8227                    max_hops = Some(*value as usize);
8228                } else {
8229                    return Err(RedDBError::Query(format!(
8230                        "table function 'shortest_path' has no named argument '{key}' (expected 'src', 'dst', 'max_hops')"
8231                    )));
8232                }
8233            }
8234            let src = src.ok_or_else(|| {
8235                RedDBError::Query(
8236                    "table function 'shortest_path' requires named argument 'src'".to_string(),
8237                )
8238            })?;
8239            let dst = dst.ok_or_else(|| {
8240                RedDBError::Query(
8241                    "table function 'shortest_path' requires named argument 'dst'".to_string(),
8242                )
8243            })?;
8244
8245            // Columns are always present; an unreachable pair (within the
8246            // optional `max_hops` budget) simply yields zero rows — never an
8247            // error. `hop` is the 0-based index from the source;
8248            // `cumulative_weight` is the running path weight (0 at the source,
8249            // the total at the destination). Edges are treated as undirected,
8250            // consistent with `components` / `louvain`.
8251            let mut result = UnifiedResult::with_columns(vec![
8252                "hop".into(),
8253                "node_id".into(),
8254                "cumulative_weight".into(),
8255            ]);
8256            if let Some(path) =
8257                graph_algorithms::shortest_path(&nodes, &edges, &src, &dst, max_hops)
8258            {
8259                for (hop, (node_id, cumulative_weight)) in path.into_iter().enumerate() {
8260                    let mut record = UnifiedRecord::new();
8261                    record.set("hop", Value::Integer(hop as i64));
8262                    record.set("node_id", Value::text(node_id));
8263                    record.set("cumulative_weight", Value::Float(cumulative_weight));
8264                    result.push(record);
8265                }
8266            }
8267            return Ok(result);
8268        }
8269        // ── Centrality family (issue #797): each returns rows `(node_id,
8270        // score)` over the abstract `(nodes, edges)` graph. Like the other
8271        // graph TVFs the graph is treated as undirected and scores are
8272        // deterministic; the inline-graph form shares this dispatch. ──
8273        if name.eq_ignore_ascii_case("betweenness") {
8274            reject_named_args(name, named_args)?;
8275            return Ok(Self::centrality_result(graph_algorithms::betweenness(
8276                &nodes, &edges,
8277            )));
8278        }
8279        if name.eq_ignore_ascii_case("eigenvector") {
8280            // Optional `max_iterations` (positive integer, default 100) and
8281            // `tolerance` (finite, strictly positive, default 1e-6).
8282            let mut max_iterations = 100_usize;
8283            let mut tolerance = 1e-6_f64;
8284            for (key, value) in named_args {
8285                if key.eq_ignore_ascii_case("max_iterations") {
8286                    max_iterations = parse_positive_iterations("eigenvector", value)?;
8287                } else if key.eq_ignore_ascii_case("tolerance") {
8288                    if !value.is_finite() || *value <= 0.0 {
8289                        return Err(RedDBError::Query(format!(
8290                            "table function 'eigenvector' tolerance must be > 0, got {value}"
8291                        )));
8292                    }
8293                    tolerance = *value;
8294                } else {
8295                    return Err(RedDBError::Query(format!(
8296                        "table function 'eigenvector' has no named argument '{key}' (expected 'max_iterations' or 'tolerance')"
8297                    )));
8298                }
8299            }
8300            return Ok(Self::centrality_result(graph_algorithms::eigenvector(
8301                &nodes,
8302                &edges,
8303                max_iterations,
8304                tolerance,
8305            )));
8306        }
8307        if name.eq_ignore_ascii_case("pagerank") {
8308            // Optional `damping` (in (0, 1), default 0.85) and `max_iterations`
8309            // (positive integer, default 100).
8310            let mut damping = 0.85_f64;
8311            let mut max_iterations = 100_usize;
8312            for (key, value) in named_args {
8313                if key.eq_ignore_ascii_case("damping") {
8314                    if !value.is_finite() || *value <= 0.0 || *value >= 1.0 {
8315                        return Err(RedDBError::Query(format!(
8316                            "table function 'pagerank' damping must be in (0, 1), got {value}"
8317                        )));
8318                    }
8319                    damping = *value;
8320                } else if key.eq_ignore_ascii_case("max_iterations") {
8321                    max_iterations = parse_positive_iterations("pagerank", value)?;
8322                } else {
8323                    return Err(RedDBError::Query(format!(
8324                        "table function 'pagerank' has no named argument '{key}' (expected 'damping' or 'max_iterations')"
8325                    )));
8326                }
8327            }
8328            return Ok(Self::centrality_result(graph_algorithms::pagerank(
8329                &nodes,
8330                &edges,
8331                damping,
8332                max_iterations,
8333            )));
8334        }
8335        Err(RedDBError::Query(format!("unknown table function: {name}")))
8336    }
8337
8338    /// `components(<graph_collection>)` — returns rows `(node_id, island_id)`.
8339    ///
8340    /// Materializes the active graph (nodes + weighted edges) read-only and
8341    /// runs the pure `graph_algorithms::connected_components`. Edges are
8342    /// treated as undirected; island ids are deterministic (ascending order of
8343    /// each component's smallest node).
8344    fn execute_components_tvf(
8345        &self,
8346        _collection: &str,
8347    ) -> RedDBResult<crate::storage::query::unified::UnifiedResult> {
8348        use crate::storage::engine::graph_algorithms;
8349        use crate::storage::query::unified::UnifiedResult;
8350        use crate::storage::schema::Value;
8351
8352        // Read-only materialization of the full active graph. The named
8353        // collection identifies the active graph scope; passing `None` for the
8354        // projection uses the full graph store (the same result
8355        // `active_graph_projection` yields when no projection is registered).
8356        // Materialization never mutates any store.
8357        let graph = super::graph_dsl::materialize_graph_with_projection(
8358            self.inner.db.store().as_ref(),
8359            None,
8360        )?;
8361
8362        // Materialize abstract inputs for the pure algorithm.
8363        let nodes: Vec<String> = graph.iter_nodes().map(|n| n.id.clone()).collect();
8364        let edges: Vec<(String, String, graph_algorithms::Weight)> = graph
8365            .iter_all_edges()
8366            .into_iter()
8367            .map(|e| (e.source_id, e.target_id, e.weight))
8368            .collect();
8369
8370        let assignment = graph_algorithms::connected_components(&nodes, &edges);
8371
8372        // Project into a UnifiedResult with columns ["node_id", "island_id"].
8373        let mut result = UnifiedResult::with_columns(vec!["node_id".into(), "island_id".into()]);
8374        for (node_id, island_id) in assignment {
8375            let mut record = UnifiedRecord::new();
8376            record.set("node_id", Value::text(node_id));
8377            record.set("island_id", Value::Integer(island_id as i64));
8378            result.push(record);
8379        }
8380        Ok(result)
8381    }
8382
8383    /// `louvain(<graph> [, resolution => <f64>])` — returns rows
8384    /// `(node_id, community_id)` (issue #796).
8385    ///
8386    /// Materializes the active graph (nodes + weighted edges) read-only and
8387    /// runs the pure, deterministic `graph_algorithms::louvain`. Edges are
8388    /// treated as undirected; community ids are assigned in ascending order of
8389    /// each community's smallest node, so identical input + resolution always
8390    /// yields identical rows. Like `components`, the v0 form runs over the
8391    /// whole graph store regardless of the collection argument value.
8392    fn execute_louvain_tvf(
8393        &self,
8394        _collection: &str,
8395        resolution: f64,
8396    ) -> RedDBResult<crate::storage::query::unified::UnifiedResult> {
8397        use crate::storage::engine::graph_algorithms;
8398        use crate::storage::query::unified::UnifiedResult;
8399        use crate::storage::schema::Value;
8400
8401        let graph = super::graph_dsl::materialize_graph_with_projection(
8402            self.inner.db.store().as_ref(),
8403            None,
8404        )?;
8405
8406        let nodes: Vec<String> = graph.iter_nodes().map(|n| n.id.clone()).collect();
8407        let edges: Vec<(String, String, graph_algorithms::Weight)> = graph
8408            .iter_all_edges()
8409            .into_iter()
8410            .map(|e| (e.source_id, e.target_id, e.weight))
8411            .collect();
8412
8413        let assignment = graph_algorithms::louvain(&nodes, &edges, resolution);
8414
8415        // Project into a UnifiedResult with columns ["node_id", "community_id"].
8416        let mut result = UnifiedResult::with_columns(vec!["node_id".into(), "community_id".into()]);
8417        for (node_id, community_id) in assignment {
8418            let mut record = UnifiedRecord::new();
8419            record.set("node_id", Value::text(node_id));
8420            record.set("community_id", Value::Integer(community_id as i64));
8421            result.push(record);
8422        }
8423        Ok(result)
8424    }
8425
8426    /// Project `(node_id, score)` centrality rows into a `UnifiedResult` with
8427    /// columns `["node_id", "score"]`; scores are `Value::Float`.
8428    fn centrality_result(
8429        rows: Vec<(String, f64)>,
8430    ) -> crate::storage::query::unified::UnifiedResult {
8431        use crate::storage::query::unified::UnifiedResult;
8432        use crate::storage::schema::Value;
8433        let mut result = UnifiedResult::with_columns(vec!["node_id".into(), "score".into()]);
8434        for (node_id, score) in rows {
8435            let mut record = UnifiedRecord::new();
8436            record.set("node_id", Value::text(node_id));
8437            record.set("score", Value::Float(score));
8438            result.push(record);
8439        }
8440        result
8441    }
8442
8443    /// Ultra-fast path: detect `SELECT * FROM table WHERE _entity_id = N` by string pattern
8444    /// and execute it without SQL parsing or planning. Returns None if pattern doesn't match.
8445    fn try_fast_entity_lookup(&self, query: &str) -> Option<RedDBResult<RuntimeQueryResult>> {
8446        // Pattern: "SELECT * FROM <table> WHERE _entity_id = <id>"
8447        // or "SELECT * FROM <table> WHERE _entity_id =<id>"
8448        let q = query.trim();
8449        if !q.starts_with("SELECT") && !q.starts_with("select") {
8450            return None;
8451        }
8452
8453        // Find "WHERE _entity_id = " or "WHERE _entity_id ="
8454        let where_pos = q
8455            .find("WHERE _entity_id")
8456            .or_else(|| q.find("where _entity_id"))?;
8457        let after_field = &q[where_pos + 16..].trim_start(); // skip "WHERE _entity_id"
8458        let after_eq = after_field.strip_prefix('=')?.trim_start();
8459
8460        // Parse the entity ID number
8461        let id_str = after_eq.trim();
8462        let entity_id: u64 = id_str.parse().ok()?;
8463
8464        // Extract table name: between "FROM " and " WHERE"
8465        let from_pos = q.find("FROM ").or_else(|| q.find("from "))? + 5;
8466        let table = q[from_pos..where_pos].trim();
8467        if table.is_empty()
8468            || table.contains(' ') && !table.contains(" AS ") && !table.contains(" as ")
8469        {
8470            return None; // complex query, fall through
8471        }
8472        let table_name = table.split_whitespace().next()?;
8473
8474        // Direct entity lookup — skips SQL parse, plan cache, result
8475        // cache, view rewriter, RLS gate. Safe because the gating in
8476        // `execute_query` guarantees no scope override / no
8477        // transaction context is active. MVCC visibility is still
8478        // honoured against the current snapshot.
8479        let store = self.inner.db.store();
8480        let entity = store
8481            .get(
8482                table_name,
8483                crate::storage::unified::EntityId::new(entity_id),
8484            )
8485            .filter(entity_visible_under_current_snapshot)
8486            .filter(|entity| {
8487                self.inner
8488                    .db
8489                    .replica_allows_entity_at_read(table_name, entity)
8490            });
8491
8492        let count = if entity.is_some() { 1u64 } else { 0 };
8493
8494        // Materialize a record so downstream consumers that walk
8495        // `result.records` (embedded runtime API, decrypt pass, CLI)
8496        // see the row. Previously only `pre_serialized_json` was
8497        // filled, which caused those consumers to see zero rows and
8498        // skewed benchmarks.
8499        let records: Vec<crate::storage::query::unified::UnifiedRecord> = entity
8500            .as_ref()
8501            .and_then(|e| runtime_table_record_from_entity(e.clone()))
8502            .into_iter()
8503            .collect();
8504
8505        let json = match entity {
8506            Some(ref e) => execute_runtime_serialize_single_entity(e),
8507            None => r#"{"columns":[],"record_count":0,"selection":{"scope":"any"},"records":[]}"#
8508                .to_string(),
8509        };
8510
8511        Some(Ok(RuntimeQueryResult {
8512            query: query.to_string(),
8513            mode: crate::storage::query::modes::QueryMode::Sql,
8514            statement: "select",
8515            engine: "fast-entity-lookup",
8516            result: crate::storage::query::unified::UnifiedResult {
8517                columns: Vec::new(),
8518                records,
8519                stats: crate::storage::query::unified::QueryStats {
8520                    rows_scanned: count,
8521                    ..Default::default()
8522                },
8523                pre_serialized_json: Some(json),
8524            },
8525            affected_rows: 0,
8526            statement_type: "select",
8527            bookmark: None,
8528        }))
8529    }
8530
8531    pub(crate) fn invalidate_plan_cache(&self) {
8532        self.inner.query_cache.write().clear();
8533        self.inner
8534            .ddl_epoch
8535            .fetch_add(1, std::sync::atomic::Ordering::Release);
8536    }
8537
8538    /// Read the monotonic DDL epoch counter. Bumped by every
8539    /// `invalidate_plan_cache` call so prepared-statement holders can
8540    /// detect schema drift between PREPARE and EXECUTE.
8541    pub fn ddl_epoch(&self) -> u64 {
8542        self.inner
8543            .ddl_epoch
8544            .load(std::sync::atomic::Ordering::Acquire)
8545    }
8546
8547    pub(crate) fn clear_table_planner_stats(&self, table: &str) {
8548        let store = self.inner.db.store();
8549        crate::storage::query::planner::stats_catalog::clear_table_stats(store.as_ref(), table);
8550        self.invalidate_plan_cache();
8551    }
8552
8553    /// Replay `tenant_tables.*.column` keys from red_config at boot so
8554    /// `CREATE TABLE ... TENANT BY (col)` declarations persist across
8555    /// restarts (Phase 2.5.4). Reads every row of the `red_config`
8556    /// collection, picks the keys matching the tenant-marker shape,
8557    /// and calls `register_tenant_table` for each.
8558    ///
8559    /// Safe no-op when `red_config` doesn't exist (first boot on a
8560    /// fresh datadir).
8561    pub(crate) fn rehydrate_tenant_tables(&self) {
8562        let store = self.inner.db.store();
8563        let Some(manager) = store.get_collection("red_config") else {
8564            return;
8565        };
8566        // Replay in insertion order (SegmentManager iteration). Multiple
8567        // toggles on the same table leave several rows behind — the
8568        // last one processed wins because each register/unregister
8569        // call overwrites the in-memory state.
8570        for entity in manager.query_all(|_| true) {
8571            let crate::storage::unified::entity::EntityData::Row(row) = &entity.data else {
8572                continue;
8573            };
8574            let Some(named) = &row.named else { continue };
8575            let Some(crate::storage::schema::Value::Text(key)) = named.get("key") else {
8576                continue;
8577            };
8578            // Shape: tenant_tables.{table}.column
8579            let Some(rest) = key.strip_prefix("tenant_tables.") else {
8580                continue;
8581            };
8582            let Some((table, suffix)) = rest.rsplit_once('.') else {
8583                // Issue #205 — a `tenant_tables.*` row that doesn't
8584                // split cleanly is a schema-shape regression: the
8585                // metadata writer must always emit the `.column`
8586                // suffix, so reaching this branch means an upgrade
8587                // with incompatible state or external tampering.
8588                crate::telemetry::operator_event::OperatorEvent::SchemaCorruption {
8589                    collection: "red_config".to_string(),
8590                    detail: format!("malformed tenant_tables key: {key}"),
8591                }
8592                .emit_global();
8593                continue;
8594            };
8595            if suffix != "column" {
8596                crate::telemetry::operator_event::OperatorEvent::SchemaCorruption {
8597                    collection: "red_config".to_string(),
8598                    detail: format!("unexpected tenant_tables suffix: {key}"),
8599                }
8600                .emit_global();
8601                continue;
8602            }
8603            match named.get("value") {
8604                Some(crate::storage::schema::Value::Text(column)) => {
8605                    self.register_tenant_table(table, column);
8606                }
8607                // Null / missing value = DISABLE TENANCY marker.
8608                Some(crate::storage::schema::Value::Null) | None => {
8609                    self.unregister_tenant_table(table);
8610                }
8611                _ => {}
8612            }
8613        }
8614    }
8615
8616    /// Replay every persisted `MaterializedViewDescriptor` from the
8617    /// `red_materialized_view_defs` system collection (issue #593
8618    /// slice 9a). For each descriptor, re-parse the original SQL,
8619    /// extract the `QueryExpr::CreateView` it produced, and populate
8620    /// the in-memory registries (`inner.views` and
8621    /// `inner.materialized_views`) directly — no write paths run, so
8622    /// rehydrate does not re-persist what it just read.
8623    ///
8624    /// Malformed rows (missing `name`/`source_sql`, parse errors) are
8625    /// skipped with a `SchemaCorruption` operator event so a single
8626    /// bad entry does not block startup.
8627    pub(crate) fn rehydrate_materialized_view_descriptors(&self) {
8628        let store = self.inner.db.store();
8629        let descriptors = crate::runtime::continuous_materialized_view::load_all(store.as_ref());
8630        for descriptor in descriptors {
8631            let parsed = match crate::storage::query::parser::parse(&descriptor.source_sql) {
8632                Ok(qc) => qc,
8633                Err(err) => {
8634                    crate::telemetry::operator_event::OperatorEvent::SchemaCorruption {
8635                        collection:
8636                            crate::runtime::continuous_materialized_view::CATALOG_COLLECTION
8637                                .to_string(),
8638                        detail: format!(
8639                            "failed to re-parse materialized-view source for {}: {err}",
8640                            descriptor.name
8641                        ),
8642                    }
8643                    .emit_global();
8644                    continue;
8645                }
8646            };
8647            let crate::storage::query::ast::QueryExpr::CreateView(create) = parsed.query else {
8648                crate::telemetry::operator_event::OperatorEvent::SchemaCorruption {
8649                    collection: crate::runtime::continuous_materialized_view::CATALOG_COLLECTION
8650                        .to_string(),
8651                    detail: format!(
8652                        "materialized-view source for {} did not re-parse as CREATE VIEW",
8653                        descriptor.name
8654                    ),
8655                }
8656                .emit_global();
8657                continue;
8658            };
8659            // Populate in-memory view registry.
8660            let view_name = create.name.clone();
8661            self.inner
8662                .views
8663                .write()
8664                .insert(view_name.clone(), Arc::new(create));
8665            // Materialized cache slot (data empty until next REFRESH).
8666            use crate::storage::cache::result::{MaterializedViewDef, RefreshPolicy};
8667            let refresh = match descriptor.refresh_every_ms {
8668                Some(ms) => RefreshPolicy::Periodic(std::time::Duration::from_millis(ms)),
8669                None => RefreshPolicy::Manual,
8670            };
8671            let def = MaterializedViewDef {
8672                name: view_name.clone(),
8673                query: format!("<parsed view {}>", view_name),
8674                dependencies: descriptor.source_collections.clone(),
8675                refresh,
8676                retention_duration_ms: descriptor.retention_duration_ms,
8677            };
8678            self.inner.materialized_views.write().register(def);
8679            if let Err(err) = self.ensure_materialized_view_backing(&view_name) {
8680                crate::telemetry::operator_event::OperatorEvent::SchemaCorruption {
8681                    collection: crate::runtime::continuous_materialized_view::CATALOG_COLLECTION
8682                        .to_string(),
8683                    detail: format!(
8684                        "failed to rehydrate backing collection for materialized view {view_name}: {err}"
8685                    ),
8686                }
8687                .emit_global();
8688            }
8689        }
8690        // A rehydrated view shape may differ from any plans the cache
8691        // bootstrapped before this method ran — flush to be safe.
8692        self.invalidate_plan_cache();
8693    }
8694
8695    pub(crate) fn rehydrate_declared_column_schemas(&self) {
8696        let store = self.inner.db.store();
8697        for contract in self.inner.db.collection_contracts() {
8698            let columns: Vec<String> = contract
8699                .declared_columns
8700                .iter()
8701                .map(|column| column.name.clone())
8702                .collect();
8703            let Some(manager) = store.get_collection(&contract.name) else {
8704                continue;
8705            };
8706            manager.set_column_schema_if_empty(columns);
8707        }
8708    }
8709
8710    /// Register a table as tenant-scoped (Phase 2.5.4). Installs the
8711    /// in-memory column mapping, the implicit RLS policy, and enables
8712    /// row-level security on the table. Idempotent — re-registering
8713    /// the same `(table, column)` replaces the prior auto-policy.
8714    pub fn register_tenant_table(&self, table: &str, column: &str) {
8715        use crate::storage::query::ast::{
8716            CompareOp, CreatePolicyQuery, Expr, FieldRef, Filter, Span,
8717        };
8718        self.inner
8719            .tenant_tables
8720            .write()
8721            .insert(table.to_string(), column.to_string());
8722
8723        // Build the policy: col = CURRENT_TENANT()
8724        // Uses CompareExpr so the comparison happens at runtime against
8725        // the thread-local tenant value read by the CURRENT_TENANT
8726        // scalar. Spans are synthetic — there's no source location for
8727        // an auto-generated policy.
8728        let lhs = Expr::Column {
8729            field: FieldRef::TableColumn {
8730                table: table.to_string(),
8731                column: column.to_string(),
8732            },
8733            span: Span::synthetic(),
8734        };
8735        let rhs = Expr::FunctionCall {
8736            name: "CURRENT_TENANT".to_string(),
8737            args: Vec::new(),
8738            span: Span::synthetic(),
8739        };
8740        let policy_filter = Filter::CompareExpr {
8741            lhs,
8742            op: CompareOp::Eq,
8743            rhs,
8744        };
8745
8746        let policy = CreatePolicyQuery {
8747            name: "__tenant_iso".to_string(),
8748            table: table.to_string(),
8749            action: None, // None = ALL actions (SELECT/INSERT/UPDATE/DELETE)
8750            role: None,   // None = every role
8751            using: Box::new(policy_filter),
8752            // Auto-tenancy defaults to Table targets. Collections of
8753            // other kinds (graph / vector / queue / timeseries) that
8754            // opt in via `ALTER ... ENABLE TENANCY` should use the
8755            // matching kind — but for now we keep the auto-policy
8756            // kind-agnostic so the evaluator can apply it to any
8757            // entity living in the collection.
8758            target_kind: crate::storage::query::ast::PolicyTargetKind::Table,
8759        };
8760
8761        // Replace any prior auto-policy for this table (column rename).
8762        self.inner.rls_policies.write().insert(
8763            (table.to_string(), "__tenant_iso".to_string()),
8764            Arc::new(policy),
8765        );
8766        self.inner
8767            .rls_enabled_tables
8768            .write()
8769            .insert(table.to_string());
8770
8771        // Auto-build a hash index on the tenant column. Every read/write
8772        // against a tenant-scoped table carries an implicit
8773        // `col = CURRENT_TENANT()` predicate from the auto-policy, so an
8774        // index on that column is on the hot path of every query. Without
8775        // it, every SELECT/UPDATE/DELETE degrades to a full scan.
8776        self.ensure_tenant_index(table, column);
8777    }
8778
8779    /// Auto-create the hash index that backs the tenant-iso RLS predicate.
8780    /// Skipped when:
8781    ///   * the column is dotted (nested path — flat secondary indices
8782    ///     don't cover those today; RLS still works via the policy)
8783    ///   * `__tenant_idx_{table}` already exists (idempotent on rehydrate)
8784    ///   * the user already registered an index whose first column matches
8785    ///     (avoids redundant duplicates of a user-defined composite)
8786    fn ensure_tenant_index(&self, table: &str, column: &str) {
8787        if column.contains('.') {
8788            return;
8789        }
8790        let index_name = format!("__tenant_idx_{table}");
8791        let registry = self.inner.index_store.list_indices(table);
8792        if registry.iter().any(|idx| idx.name == index_name) {
8793            return;
8794        }
8795        if registry
8796            .iter()
8797            .any(|idx| idx.columns.first().map(|c| c.as_str()) == Some(column))
8798        {
8799            return;
8800        }
8801
8802        let store = self.inner.db.store();
8803        let Some(manager) = store.get_collection(table) else {
8804            return;
8805        };
8806        let entities = manager.query_all(|_| true);
8807        let entity_fields: Vec<(
8808            crate::storage::unified::EntityId,
8809            Vec<(String, crate::storage::schema::Value)>,
8810        )> = entities
8811            .iter()
8812            .map(|e| {
8813                let fields = match &e.data {
8814                    crate::storage::EntityData::Row(row) => {
8815                        if let Some(ref named) = row.named {
8816                            named.iter().map(|(k, v)| (k.clone(), v.clone())).collect()
8817                        } else if let Some(ref schema) = row.schema {
8818                            schema
8819                                .iter()
8820                                .zip(row.columns.iter())
8821                                .map(|(k, v)| (k.clone(), v.clone()))
8822                                .collect()
8823                        } else {
8824                            Vec::new()
8825                        }
8826                    }
8827                    crate::storage::EntityData::Node(node) => node
8828                        .properties
8829                        .iter()
8830                        .map(|(k, v)| (k.clone(), v.clone()))
8831                        .collect(),
8832                    _ => Vec::new(),
8833                };
8834                (e.id, fields)
8835            })
8836            .collect();
8837
8838        let columns = vec![column.to_string()];
8839        if self
8840            .inner
8841            .index_store
8842            .create_index(
8843                &index_name,
8844                table,
8845                &columns,
8846                super::index_store::IndexMethodKind::Hash,
8847                false,
8848                &entity_fields,
8849            )
8850            .is_err()
8851        {
8852            return;
8853        }
8854        self.inner
8855            .index_store
8856            .register(super::index_store::RegisteredIndex {
8857                name: index_name,
8858                collection: table.to_string(),
8859                columns,
8860                method: super::index_store::IndexMethodKind::Hash,
8861                unique: false,
8862            });
8863        self.invalidate_plan_cache();
8864    }
8865
8866    /// Drop the auto-generated tenant index, if one exists. Called from
8867    /// `unregister_tenant_table` so DISABLE TENANCY / DROP TABLE clean up.
8868    fn drop_tenant_index(&self, table: &str) {
8869        let index_name = format!("__tenant_idx_{table}");
8870        self.inner.index_store.drop_index(&index_name, table);
8871    }
8872
8873    /// Retrieve the tenant column for a table, if any (Phase 2.5.4).
8874    /// Used by the INSERT auto-fill path to know which column to
8875    /// populate with `current_tenant()` when the user didn't name it.
8876    pub fn tenant_column(&self, table: &str) -> Option<String> {
8877        self.inner.tenant_tables.read().get(table).cloned()
8878    }
8879
8880    /// Remove a table's tenant registration (Phase 2.5.4). Called by
8881    /// DROP TABLE / ALTER TABLE DISABLE TENANCY. Removes the auto-policy
8882    /// but leaves any user-installed explicit policies intact.
8883    pub fn unregister_tenant_table(&self, table: &str) {
8884        self.inner.tenant_tables.write().remove(table);
8885        self.inner
8886            .rls_policies
8887            .write()
8888            .remove(&(table.to_string(), "__tenant_iso".to_string()));
8889        self.drop_tenant_index(table);
8890        // Only clear RLS enablement if no other policies remain.
8891        let has_other_policies = self
8892            .inner
8893            .rls_policies
8894            .read()
8895            .keys()
8896            .any(|(t, _)| t == table);
8897        if !has_other_policies {
8898            self.inner.rls_enabled_tables.write().remove(table);
8899        }
8900    }
8901
8902    /// Record that the running transaction has marked `id` in `collection`
8903    /// for deletion (Phase 2.3.2b MVCC tombstones). `stamper_xid` is the
8904    /// xid that was written into `xmax` — either the parent txn xid or
8905    /// the innermost savepoint sub-xid. Savepoint rollback filters by
8906    /// this xid to revive only its own tombstones.
8907    pub(crate) fn record_pending_tombstone(
8908        &self,
8909        conn_id: u64,
8910        collection: &str,
8911        id: crate::storage::unified::entity::EntityId,
8912        stamper_xid: crate::storage::transaction::snapshot::Xid,
8913        previous_xmax: crate::storage::transaction::snapshot::Xid,
8914    ) {
8915        self.inner
8916            .pending_tombstones
8917            .write()
8918            .entry(conn_id)
8919            .or_default()
8920            .push((collection.to_string(), id, stamper_xid, previous_xmax));
8921    }
8922
8923    pub(crate) fn record_pending_versioned_update(
8924        &self,
8925        conn_id: u64,
8926        collection: &str,
8927        old_id: crate::storage::unified::entity::EntityId,
8928        new_id: crate::storage::unified::entity::EntityId,
8929        stamper_xid: crate::storage::transaction::snapshot::Xid,
8930        previous_xmax: crate::storage::transaction::snapshot::Xid,
8931    ) {
8932        self.inner
8933            .pending_versioned_updates
8934            .write()
8935            .entry(conn_id)
8936            .or_default()
8937            .push((
8938                collection.to_string(),
8939                old_id,
8940                new_id,
8941                stamper_xid,
8942                previous_xmax,
8943            ));
8944    }
8945
8946    fn with_deferred_store_wal_if_transaction<T>(
8947        &self,
8948        f: impl FnOnce() -> RedDBResult<T>,
8949    ) -> RedDBResult<T> {
8950        let conn_id = current_connection_id();
8951        if !self.inner.tx_contexts.read().contains_key(&conn_id) {
8952            return f();
8953        }
8954
8955        crate::storage::UnifiedStore::begin_deferred_store_wal_capture();
8956        let result = f();
8957        let captured = crate::storage::UnifiedStore::take_deferred_store_wal_capture();
8958        match result {
8959            Ok(value) => {
8960                self.record_pending_store_wal_actions(conn_id, captured);
8961                Ok(value)
8962            }
8963            Err(err) => Err(err),
8964        }
8965    }
8966
8967    fn with_deferred_store_wal_for_dml<T>(
8968        &self,
8969        capture_autocommit_events: bool,
8970        f: impl FnOnce() -> RedDBResult<T>,
8971    ) -> RedDBResult<T> {
8972        let conn_id = current_connection_id();
8973        if self.inner.tx_contexts.read().contains_key(&conn_id) {
8974            return self.with_deferred_store_wal_if_transaction(f);
8975        }
8976        if !capture_autocommit_events {
8977            return f();
8978        }
8979
8980        crate::storage::UnifiedStore::begin_deferred_store_wal_capture();
8981        let result = f();
8982        let captured = crate::storage::UnifiedStore::take_deferred_store_wal_capture();
8983        self.inner
8984            .db
8985            .store()
8986            .append_deferred_store_wal_actions(captured)
8987            .map_err(|err| RedDBError::Internal(err.to_string()))?;
8988        result
8989    }
8990
8991    fn insert_may_emit_events(&self, query: &InsertQuery) -> bool {
8992        !query.suppress_events
8993            && self.collection_has_event_subscriptions_for_operation(
8994                &query.table,
8995                crate::catalog::SubscriptionOperation::Insert,
8996            )
8997    }
8998
8999    fn update_may_emit_events(&self, query: &UpdateQuery) -> bool {
9000        !query.suppress_events
9001            && self.collection_has_event_subscriptions_for_operation(
9002                &query.table,
9003                crate::catalog::SubscriptionOperation::Update,
9004            )
9005    }
9006
9007    fn delete_may_emit_events(&self, query: &DeleteQuery) -> bool {
9008        !query.suppress_events
9009            && self.collection_has_event_subscriptions_for_operation(
9010                &query.table,
9011                crate::catalog::SubscriptionOperation::Delete,
9012            )
9013    }
9014
9015    fn collection_has_event_subscriptions_for_operation(
9016        &self,
9017        collection: &str,
9018        operation: crate::catalog::SubscriptionOperation,
9019    ) -> bool {
9020        let Some(contract) = self.db().collection_contract_arc(collection) else {
9021            return false;
9022        };
9023        contract.subscriptions.iter().any(|subscription| {
9024            subscription.enabled
9025                && (subscription.ops_filter.is_empty()
9026                    || subscription.ops_filter.contains(&operation))
9027        })
9028    }
9029
9030    fn record_pending_store_wal_actions(
9031        &self,
9032        conn_id: u64,
9033        actions: crate::storage::unified::DeferredStoreWalActions,
9034    ) {
9035        if actions.is_empty() {
9036            return;
9037        }
9038        let mut guard = self.inner.pending_store_wal_actions.write();
9039        guard.entry(conn_id).or_default().extend(actions);
9040    }
9041
9042    fn flush_pending_store_wal_actions(&self, conn_id: u64) -> RedDBResult<()> {
9043        let Some(actions) = self
9044            .inner
9045            .pending_store_wal_actions
9046            .write()
9047            .remove(&conn_id)
9048        else {
9049            return Ok(());
9050        };
9051        self.inner
9052            .db
9053            .store()
9054            .append_deferred_store_wal_actions(actions)
9055            .map_err(|err| RedDBError::Internal(err.to_string()))
9056    }
9057
9058    fn discard_pending_store_wal_actions(&self, conn_id: u64) {
9059        self.inner
9060            .pending_store_wal_actions
9061            .write()
9062            .remove(&conn_id);
9063    }
9064
9065    fn xid_conflicts_with_snapshot(
9066        &self,
9067        xid: crate::storage::transaction::snapshot::Xid,
9068        snapshot: &crate::storage::transaction::snapshot::Snapshot,
9069        own_xids: &std::collections::HashSet<crate::storage::transaction::snapshot::Xid>,
9070    ) -> bool {
9071        xid != 0
9072            && !own_xids.contains(&xid)
9073            && !self.inner.snapshot_manager.is_aborted(xid)
9074            && !self.inner.snapshot_manager.is_active(xid)
9075            && (xid > snapshot.xid || snapshot.in_progress.contains(&xid))
9076    }
9077
9078    fn conflict_error(
9079        collection: &str,
9080        logical_id: crate::storage::unified::entity::EntityId,
9081        xid: crate::storage::transaction::snapshot::Xid,
9082    ) -> RedDBError {
9083        RedDBError::Query(format!(
9084            "serialization conflict: table row {collection}/{} was modified by concurrent transaction {xid}",
9085            logical_id.raw()
9086        ))
9087    }
9088
9089    fn check_logical_row_conflict(
9090        &self,
9091        collection: &str,
9092        logical_id: crate::storage::unified::entity::EntityId,
9093        excluded_ids: &[crate::storage::unified::entity::EntityId],
9094        snapshot: &crate::storage::transaction::snapshot::Snapshot,
9095        own_xids: &std::collections::HashSet<crate::storage::transaction::snapshot::Xid>,
9096    ) -> RedDBResult<()> {
9097        let store = self.inner.db.store();
9098        let Some(manager) = store.get_collection(collection) else {
9099            return Ok(());
9100        };
9101
9102        for candidate in manager.query_all(|_| true) {
9103            if excluded_ids.contains(&candidate.id) || candidate.logical_id() != logical_id {
9104                continue;
9105            }
9106            if self.xid_conflicts_with_snapshot(candidate.xmin, snapshot, own_xids) {
9107                return Err(Self::conflict_error(collection, logical_id, candidate.xmin));
9108            }
9109            if self.xid_conflicts_with_snapshot(candidate.xmax, snapshot, own_xids) {
9110                return Err(Self::conflict_error(collection, logical_id, candidate.xmax));
9111            }
9112        }
9113        Ok(())
9114    }
9115
9116    pub(crate) fn check_table_row_write_conflicts(
9117        &self,
9118        conn_id: u64,
9119        snapshot: &crate::storage::transaction::snapshot::Snapshot,
9120        own_xids: &std::collections::HashSet<crate::storage::transaction::snapshot::Xid>,
9121    ) -> RedDBResult<()> {
9122        let versioned_updates = self
9123            .inner
9124            .pending_versioned_updates
9125            .read()
9126            .get(&conn_id)
9127            .cloned()
9128            .unwrap_or_default();
9129        let tombstones = self
9130            .inner
9131            .pending_tombstones
9132            .read()
9133            .get(&conn_id)
9134            .cloned()
9135            .unwrap_or_default();
9136
9137        let store = self.inner.db.store();
9138        for (collection, old_id, new_id, xid, previous_xmax) in versioned_updates {
9139            let Some(manager) = store.get_collection(&collection) else {
9140                continue;
9141            };
9142            let Some(old) = manager.get(old_id) else {
9143                continue;
9144            };
9145            let logical_id = old.logical_id();
9146            if self.xid_conflicts_with_snapshot(previous_xmax, snapshot, own_xids) {
9147                return Err(Self::conflict_error(&collection, logical_id, previous_xmax));
9148            }
9149            if old.xmax != xid && self.xid_conflicts_with_snapshot(old.xmax, snapshot, own_xids) {
9150                return Err(Self::conflict_error(&collection, logical_id, old.xmax));
9151            }
9152            self.check_logical_row_conflict(
9153                &collection,
9154                logical_id,
9155                &[old_id, new_id],
9156                snapshot,
9157                own_xids,
9158            )?;
9159        }
9160
9161        for (collection, id, xid, previous_xmax) in tombstones {
9162            let Some(manager) = store.get_collection(&collection) else {
9163                continue;
9164            };
9165            let Some(entity) = manager.get(id) else {
9166                continue;
9167            };
9168            let logical_id = entity.logical_id();
9169            if self.xid_conflicts_with_snapshot(previous_xmax, snapshot, own_xids) {
9170                return Err(Self::conflict_error(&collection, logical_id, previous_xmax));
9171            }
9172            if entity.xmax != xid
9173                && self.xid_conflicts_with_snapshot(entity.xmax, snapshot, own_xids)
9174            {
9175                return Err(Self::conflict_error(&collection, logical_id, entity.xmax));
9176            }
9177            self.check_logical_row_conflict(&collection, logical_id, &[id], snapshot, own_xids)?;
9178        }
9179
9180        Ok(())
9181    }
9182
9183    pub(crate) fn restore_pending_write_stamps(&self, conn_id: u64) {
9184        let versioned_updates = self
9185            .inner
9186            .pending_versioned_updates
9187            .read()
9188            .get(&conn_id)
9189            .cloned()
9190            .unwrap_or_default();
9191        let tombstones = self
9192            .inner
9193            .pending_tombstones
9194            .read()
9195            .get(&conn_id)
9196            .cloned()
9197            .unwrap_or_default();
9198
9199        let store = self.inner.db.store();
9200        for (collection, old_id, _new_id, xid, _previous_xmax) in versioned_updates {
9201            if let Some(manager) = store.get_collection(&collection) {
9202                if let Some(mut entity) = manager.get(old_id) {
9203                    entity.set_xmax(xid);
9204                    let _ = manager.update(entity);
9205                }
9206            }
9207        }
9208        for (collection, id, xid, _previous_xmax) in tombstones {
9209            if let Some(manager) = store.get_collection(&collection) {
9210                if let Some(mut entity) = manager.get(id) {
9211                    entity.set_xmax(xid);
9212                    let _ = manager.update(entity);
9213                }
9214            }
9215        }
9216    }
9217
9218    pub(crate) fn finalize_pending_versioned_updates(&self, conn_id: u64) {
9219        self.inner
9220            .pending_versioned_updates
9221            .write()
9222            .remove(&conn_id);
9223    }
9224
9225    pub(crate) fn revive_pending_versioned_updates(&self, conn_id: u64) {
9226        let Some(pending) = self
9227            .inner
9228            .pending_versioned_updates
9229            .write()
9230            .remove(&conn_id)
9231        else {
9232            return;
9233        };
9234
9235        let store = self.inner.db.store();
9236        for (collection, old_id, new_id, xid, previous_xmax) in pending {
9237            if let Some(manager) = store.get_collection(&collection) {
9238                if let Some(mut old) = manager.get(old_id) {
9239                    if old.xmax == xid {
9240                        old.set_xmax(previous_xmax);
9241                        let _ = manager.update(old);
9242                    }
9243                }
9244            }
9245            let _ = store.delete_batch(&collection, &[new_id]);
9246        }
9247    }
9248
9249    pub(crate) fn revive_versioned_updates_since(&self, conn_id: u64, stamper_xid: u64) -> usize {
9250        let mut guard = self.inner.pending_versioned_updates.write();
9251        let Some(pending) = guard.get_mut(&conn_id) else {
9252            return 0;
9253        };
9254
9255        let store = self.inner.db.store();
9256        let mut reverted = 0usize;
9257        pending.retain(|(collection, old_id, new_id, xid, previous_xmax)| {
9258            if *xid < stamper_xid {
9259                return true;
9260            }
9261            if let Some(manager) = store.get_collection(collection) {
9262                if let Some(mut old) = manager.get(*old_id) {
9263                    if old.xmax == *xid {
9264                        old.set_xmax(*previous_xmax);
9265                        let _ = manager.update(old);
9266                    }
9267                }
9268            }
9269            let _ = store.delete_batch(collection, &[*new_id]);
9270            reverted += 1;
9271            false
9272        });
9273        if pending.is_empty() {
9274            guard.remove(&conn_id);
9275        }
9276        reverted
9277    }
9278
9279    /// Flush tombstones on COMMIT. The xmax stamp is already the durable
9280    /// delete marker; commit only drops the rollback journal and emits
9281    /// side effects. Physical reclamation is left for VACUUM so old
9282    /// snapshots can still resolve the pre-delete row version.
9283    pub(crate) fn finalize_pending_tombstones(&self, conn_id: u64) {
9284        let Some(pending) = self.inner.pending_tombstones.write().remove(&conn_id) else {
9285            return;
9286        };
9287        if pending.is_empty() {
9288            return;
9289        }
9290
9291        let store = self.inner.db.store();
9292        for (collection, id, _xid, _previous_xmax) in pending {
9293            store.context_index().remove_entity(id);
9294            self.cdc_emit(
9295                crate::replication::cdc::ChangeOperation::Delete,
9296                &collection,
9297                id.raw(),
9298                "entity",
9299            );
9300        }
9301    }
9302
9303    /// Revive tombstones on ROLLBACK — reset `xmax` to 0 so the tuples
9304    /// become visible again to future snapshots. Best-effort: a row
9305    /// already reclaimed by a concurrent VACUUM stays gone, but VACUUM
9306    /// never reclaims tuples whose xmax is still referenced by any
9307    /// active snapshot, so this case is only reachable via external
9308    /// storage corruption.
9309    pub(crate) fn revive_pending_tombstones(&self, conn_id: u64) {
9310        let Some(pending) = self.inner.pending_tombstones.write().remove(&conn_id) else {
9311            return;
9312        };
9313
9314        let store = self.inner.db.store();
9315        for (collection, id, xid, previous_xmax) in pending {
9316            let Some(manager) = store.get_collection(&collection) else {
9317                continue;
9318            };
9319            if let Some(mut entity) = manager.get(id) {
9320                if entity.xmax == xid {
9321                    entity.set_xmax(previous_xmax);
9322                    let _ = manager.update(entity);
9323                }
9324            }
9325        }
9326    }
9327
9328    /// Slice C of PRD #718 — accessor for the local wait registry.
9329    pub fn queue_wait_registry(
9330        &self,
9331    ) -> std::sync::Arc<crate::runtime::queue_wait_registry::QueueWaitRegistry> {
9332        self.inner.queue_wait_registry.clone()
9333    }
9334
9335    /// Buffer a `(scope, queue)` wake on the current connection so it
9336    /// fires post-COMMIT, or notify immediately if no transaction is
9337    /// open (autocommit path). The wait registry only ever observes
9338    /// notifies for committed work — rollback drops the buffer.
9339    pub(crate) fn record_queue_wake(&self, scope: &str, queue: &str) {
9340        if self.current_xid().is_some() {
9341            let conn_id = current_connection_id();
9342            self.inner
9343                .pending_queue_wakes
9344                .write()
9345                .entry(conn_id)
9346                .or_default()
9347                .push((scope.to_string(), queue.to_string()));
9348            return;
9349        }
9350        self.inner.queue_wait_registry.notify(scope, queue);
9351    }
9352
9353    pub(crate) fn finalize_pending_queue_wakes(&self, conn_id: u64) {
9354        let Some(pending) = self.inner.pending_queue_wakes.write().remove(&conn_id) else {
9355            return;
9356        };
9357        for (scope, queue) in pending {
9358            self.inner.queue_wait_registry.notify(&scope, &queue);
9359        }
9360    }
9361
9362    pub(crate) fn discard_pending_queue_wakes(&self, conn_id: u64) {
9363        self.inner.pending_queue_wakes.write().remove(&conn_id);
9364    }
9365
9366    pub(crate) fn release_pending_claim_locks(&self, conn_id: u64) {
9367        self.inner
9368            .pending_claim_locks
9369            .write()
9370            .retain(|_, owner| *owner != conn_id);
9371    }
9372
9373    pub(crate) fn finalize_pending_kv_watch_events(&self, conn_id: u64) {
9374        let Some(pending) = self.inner.pending_kv_watch_events.write().remove(&conn_id) else {
9375            return;
9376        };
9377        for event in pending {
9378            self.cdc_emit_kv(
9379                event.op,
9380                &event.collection,
9381                &event.key,
9382                0,
9383                event.before,
9384                event.after,
9385            );
9386        }
9387    }
9388
9389    pub(crate) fn discard_pending_kv_watch_events(&self, conn_id: u64) {
9390        self.inner.pending_kv_watch_events.write().remove(&conn_id);
9391    }
9392
9393    /// Materialise the entire graph store while applying MVCC visibility
9394    /// AND per-collection RLS to each candidate node and edge. Mirrors
9395    /// `materialize_graph` but routes every entity through the same
9396    /// gate the SELECT path uses, with the correct `PolicyTargetKind`
9397    /// per entity kind (`Nodes` for graph nodes, `Edges` for graph
9398    /// edges). Returns the filtered `GraphStore` plus the
9399    /// `node_id → properties` map the executor needs for `RETURN n.*`
9400    /// projections.
9401    fn materialize_graph_with_rls(
9402        &self,
9403    ) -> RedDBResult<(
9404        crate::storage::engine::GraphStore,
9405        std::collections::HashMap<
9406            String,
9407            std::collections::HashMap<String, crate::storage::schema::Value>,
9408        >,
9409        crate::storage::query::unified::EdgeProperties,
9410    )> {
9411        use crate::storage::engine::GraphStore;
9412        use crate::storage::query::ast::{PolicyAction, PolicyTargetKind};
9413        use crate::storage::unified::entity::{EntityData, EntityKind};
9414        use std::collections::{HashMap, HashSet};
9415
9416        let store = self.inner.db.store();
9417        let snap_ctx = capture_current_snapshot();
9418        let role = current_auth_identity().map(|(_, r)| r.as_str().to_string());
9419
9420        let graph = GraphStore::new();
9421        let mut node_properties: HashMap<String, HashMap<String, crate::storage::schema::Value>> =
9422            HashMap::new();
9423        let mut edge_properties: crate::storage::query::unified::EdgeProperties = HashMap::new();
9424        let mut allowed_nodes: HashSet<String> = HashSet::new();
9425
9426        // Per-collection cached compiled filters — Nodes-kind for
9427        // first pass, Edges-kind for the second. None entries mean
9428        // "RLS enabled, zero matching policy → deny all of this kind".
9429        let mut node_rls: HashMap<String, Option<crate::storage::query::ast::Filter>> =
9430            HashMap::new();
9431        let mut edge_rls: HashMap<String, Option<crate::storage::query::ast::Filter>> =
9432            HashMap::new();
9433
9434        let collections = store.list_collections();
9435
9436        // First pass — gather nodes.
9437        for collection in &collections {
9438            let Some(manager) = store.get_collection(collection) else {
9439                continue;
9440            };
9441            let entities = manager.query_all(|_| true);
9442            for entity in entities {
9443                if !entity_visible_with_context(snap_ctx.as_ref(), &entity) {
9444                    continue;
9445                }
9446                let EntityKind::GraphNode(ref node) = entity.kind else {
9447                    continue;
9448                };
9449                if !node_passes_rls(self, collection, role.as_deref(), &mut node_rls, &entity) {
9450                    continue;
9451                }
9452                let id_str = entity.id.raw().to_string();
9453                graph
9454                    .add_node_with_label(
9455                        &id_str,
9456                        &node.label,
9457                        &super::graph_node_label(&node.node_type),
9458                    )
9459                    .map_err(|err| RedDBError::Query(err.to_string()))?;
9460                allowed_nodes.insert(id_str.clone());
9461                if let EntityData::Node(node_data) = &entity.data {
9462                    node_properties.insert(id_str, node_data.properties.clone());
9463                }
9464            }
9465        }
9466
9467        // Second pass — gather edges. An edge appears only when both
9468        // endpoint nodes survived the RLS pass AND the edge itself
9469        // passes its own RLS gate.
9470        for collection in &collections {
9471            let Some(manager) = store.get_collection(collection) else {
9472                continue;
9473            };
9474            let entities = manager.query_all(|_| true);
9475            for entity in entities {
9476                if !entity_visible_with_context(snap_ctx.as_ref(), &entity) {
9477                    continue;
9478                }
9479                let EntityKind::GraphEdge(ref edge) = entity.kind else {
9480                    continue;
9481                };
9482                if !allowed_nodes.contains(&edge.from_node)
9483                    || !allowed_nodes.contains(&edge.to_node)
9484                {
9485                    continue;
9486                }
9487                if !edge_passes_rls(self, collection, role.as_deref(), &mut edge_rls, &entity) {
9488                    continue;
9489                }
9490                let weight = match &entity.data {
9491                    EntityData::Edge(e) => e.weight,
9492                    _ => edge.weight as f32 / 1000.0,
9493                };
9494                let edge_label = super::graph_edge_label(&edge.label);
9495                graph
9496                    .add_edge_with_label(&edge.from_node, &edge.to_node, &edge_label, weight)
9497                    .map_err(|err| RedDBError::Query(err.to_string()))?;
9498                if let EntityData::Edge(edge_data) = &entity.data {
9499                    edge_properties.insert(
9500                        (edge.from_node.clone(), edge_label, edge.to_node.clone()),
9501                        edge_data.properties.clone(),
9502                    );
9503                }
9504            }
9505        }
9506
9507        // Suppress unused-PolicyAction/PolicyTargetKind warnings — both
9508        // are used inside the helper closures via the per-kind helpers
9509        // declared at the bottom of this file.
9510        let _ = (PolicyAction::Select, PolicyTargetKind::Nodes);
9511
9512        Ok((graph, node_properties, edge_properties))
9513    }
9514
9515    /// Phase 1.1 MVCC universal: post-save hook that stamps `xmin` on a
9516    /// freshly-inserted entity when the current connection holds an
9517    /// open transaction. Used by graph / vector / queue / timeseries
9518    /// write paths that go through the DevX builder API (`db.node(...)
9519    /// .save()` and friends) — those live in the storage crate and
9520    /// can't reach `current_xid()` without crossing layers, so the
9521    /// application layer calls this helper right after `save()` to
9522    /// finalise the MVCC stamp.
9523    ///
9524    /// Autocommit (outside BEGIN) is a no-op — no extra lookup or
9525    /// write, so the non-transactional hot path stays untouched.
9526    ///
9527    /// Best-effort: if the collection or entity disappears between
9528    /// the save and the stamp (concurrent DROP), we silently skip.
9529    pub(crate) fn stamp_xmin_if_in_txn(
9530        &self,
9531        collection: &str,
9532        id: crate::storage::unified::entity::EntityId,
9533    ) {
9534        let Some(xid) = self.current_xid() else {
9535            return;
9536        };
9537        let store = self.inner.db.store();
9538        let Some(manager) = store.get_collection(collection) else {
9539            return;
9540        };
9541        if let Some(mut entity) = manager.get(id) {
9542            entity.set_xmin(xid);
9543            let _ = manager.update(entity);
9544        }
9545    }
9546
9547    /// Revive tombstones stamped by `stamper_xid` or any sub-xid
9548    /// allocated after it (Phase 2.3.2e savepoint rollback). Any
9549    /// pending entries with `xid < stamper_xid` stay queued because
9550    /// they belong to the enclosing scope — they'll either flush on
9551    /// COMMIT or revive on an outer ROLLBACK TO SAVEPOINT.
9552    ///
9553    /// Returns the number of tuples whose `xmax` was wiped back to 0.
9554    pub(crate) fn revive_tombstones_since(&self, conn_id: u64, stamper_xid: u64) -> usize {
9555        let mut guard = self.inner.pending_tombstones.write();
9556        let Some(pending) = guard.get_mut(&conn_id) else {
9557            return 0;
9558        };
9559
9560        let store = self.inner.db.store();
9561        let mut revived = 0usize;
9562        pending.retain(|(collection, id, xid, previous_xmax)| {
9563            if *xid < stamper_xid {
9564                // Stamped before the savepoint — keep in queue.
9565                return true;
9566            }
9567            if let Some(manager) = store.get_collection(collection) {
9568                if let Some(mut entity) = manager.get(*id) {
9569                    if entity.xmax == *xid {
9570                        entity.set_xmax(*previous_xmax);
9571                        let _ = manager.update(entity);
9572                        revived += 1;
9573                    }
9574                }
9575            }
9576            false
9577        });
9578        if pending.is_empty() {
9579            guard.remove(&conn_id);
9580        }
9581        revived
9582    }
9583
9584    /// Return the snapshot the current connection should use for visibility
9585    /// checks (Phase 2.3 PG parity).
9586    ///
9587    /// * If the connection is inside a BEGIN-wrapped transaction, reuse
9588    ///   the snapshot stored in its `TxnContext`.
9589    /// * Otherwise (autocommit), capture a fresh snapshot tied to an
9590    ///   implicit xid=0 — the read path treats pre-MVCC rows as always
9591    ///   visible so this degrades to "see everything committed".
9592    pub fn current_snapshot(&self) -> crate::storage::transaction::snapshot::Snapshot {
9593        let conn_id = current_connection_id();
9594        if let Some(ctx) = self.inner.tx_contexts.read().get(&conn_id).cloned() {
9595            return ctx.snapshot;
9596        }
9597        // Autocommit: take a fresh snapshot bounded by `peek_next_xid` so
9598        // every already-committed xid (which is strictly less) passes the
9599        // `xmin <= snap.xid` gate, while concurrently-active xids land in
9600        // the `in_progress` set and stay hidden until they commit. Using
9601        // xid=0 would incorrectly hide every MVCC-stamped tuple.
9602        let high_water = self.inner.snapshot_manager.peek_next_xid();
9603        self.inner.snapshot_manager.snapshot(high_water)
9604    }
9605
9606    /// Xid of the current connection's active transaction, or `None` when
9607    /// running outside a BEGIN/COMMIT block. Write paths call this to
9608    /// decide whether to stamp `xmin`/`xmax` on tuples.
9609    /// Phase 2.3.2e: when a savepoint is open, `writer_xid` returns the
9610    /// sub-xid so new writes can be selectively rolled back. Otherwise
9611    /// the parent txn's xid is returned, matching pre-savepoint
9612    /// behaviour. Callers that need the enclosing *transaction* xid
9613    /// (e.g. VACUUM min-active calculations) should read `ctx.xid`
9614    /// directly.
9615    pub fn current_xid(&self) -> Option<crate::storage::transaction::snapshot::Xid> {
9616        let conn_id = current_connection_id();
9617        self.inner
9618            .tx_contexts
9619            .read()
9620            .get(&conn_id)
9621            .map(|ctx| ctx.writer_xid())
9622    }
9623
9624    /// `true` when the given connection id has an open `BEGIN`. Issue
9625    /// #760 — `OpenStream` consults this to refuse output streams that
9626    /// would otherwise collide with an interactive transaction (see
9627    /// ADR 0029 "Transaction interaction"). HTTP requests pre-dating the
9628    /// connection-id plumbing run with id `0`, which never carries a
9629    /// transaction context, so this returns `false` on those paths.
9630    pub fn connection_in_transaction(&self, conn_id: u64) -> bool {
9631        self.inner.tx_contexts.read().contains_key(&conn_id)
9632    }
9633
9634    /// Access the shared `SnapshotManager` — useful for VACUUM to compute
9635    /// the oldest-active xid when reclaiming dead tuples.
9636    pub fn snapshot_manager(&self) -> Arc<crate::storage::transaction::snapshot::SnapshotManager> {
9637        Arc::clone(&self.inner.snapshot_manager)
9638    }
9639
9640    fn mvcc_vacuum_cutoff_xid(&self) -> crate::storage::transaction::snapshot::Xid {
9641        let manager = &self.inner.snapshot_manager;
9642        let next_xid = manager.peek_next_xid();
9643        let mut cutoff = next_xid;
9644        if let Some(oldest_active) = manager.oldest_active_xid() {
9645            cutoff = cutoff.min(oldest_active);
9646        }
9647        if let Some(oldest_pinned) = manager.oldest_pinned_xid() {
9648            cutoff = cutoff.min(oldest_pinned);
9649        }
9650        let retention_xids = self.config_u64("runtime.mvcc.vacuum_retention_xids", 0);
9651        if retention_xids > 0 {
9652            cutoff = cutoff.min(next_xid.saturating_sub(retention_xids));
9653        }
9654        cutoff
9655    }
9656
9657    fn rebuild_runtime_indexes_for_table(&self, table: &str) -> RedDBResult<()> {
9658        let registered = self.inner.index_store.list_indices(table);
9659        if registered.is_empty() {
9660            return Ok(());
9661        }
9662        let store = self.inner.db.store();
9663        let Some(manager) = store.get_collection(table) else {
9664            return Ok(());
9665        };
9666        let entity_fields = manager
9667            .query_all(|entity| matches!(entity.kind, crate::storage::EntityKind::TableRow { .. }))
9668            .into_iter()
9669            .map(|entity| (entity.id, table_row_index_fields(&entity)))
9670            .collect::<Vec<_>>();
9671
9672        for index in registered {
9673            self.inner.index_store.drop_index(&index.name, table);
9674            self.inner
9675                .index_store
9676                .create_index(
9677                    &index.name,
9678                    table,
9679                    &index.columns,
9680                    index.method,
9681                    index.unique,
9682                    &entity_fields,
9683                )
9684                .map_err(RedDBError::Internal)?;
9685            self.inner.index_store.register(index);
9686        }
9687        self.invalidate_plan_cache();
9688        Ok(())
9689    }
9690
9691    pub(crate) fn persist_runtime_index_descriptor(
9692        &self,
9693        index: super::index_store::RegisteredIndex,
9694    ) -> RedDBResult<()> {
9695        let store = self.inner.db.store();
9696        let _ = store.get_or_create_collection(RUNTIME_INDEX_REGISTRY_COLLECTION);
9697        let entity = crate::storage::UnifiedEntity::new(
9698            crate::storage::EntityId::new(0),
9699            crate::storage::EntityKind::TableRow {
9700                table: std::sync::Arc::from(RUNTIME_INDEX_REGISTRY_COLLECTION),
9701                row_id: 0,
9702            },
9703            crate::storage::EntityData::Row(crate::storage::RowData {
9704                columns: Vec::new(),
9705                named: Some(
9706                    [
9707                        (
9708                            "collection".to_string(),
9709                            crate::storage::schema::Value::text(index.collection.clone()),
9710                        ),
9711                        (
9712                            "name".to_string(),
9713                            crate::storage::schema::Value::text(index.name.clone()),
9714                        ),
9715                        (
9716                            "columns".to_string(),
9717                            crate::storage::schema::Value::text(index.columns.join("\u{1f}")),
9718                        ),
9719                        (
9720                            "method".to_string(),
9721                            crate::storage::schema::Value::text(index_method_kind_as_str(
9722                                index.method,
9723                            )),
9724                        ),
9725                        (
9726                            "resolution".to_string(),
9727                            crate::storage::schema::Value::Integer(i64::from(
9728                                index_method_kind_resolution(index.method),
9729                            )),
9730                        ),
9731                        (
9732                            "unique".to_string(),
9733                            crate::storage::schema::Value::Boolean(index.unique),
9734                        ),
9735                        (
9736                            "dropped".to_string(),
9737                            crate::storage::schema::Value::Boolean(false),
9738                        ),
9739                    ]
9740                    .into_iter()
9741                    .collect(),
9742                ),
9743                schema: None,
9744            }),
9745        );
9746        store
9747            .insert_auto(RUNTIME_INDEX_REGISTRY_COLLECTION, entity)
9748            .map(|_| ())
9749            .map_err(|err| RedDBError::Internal(format!("{err:?}")))
9750    }
9751
9752    pub(crate) fn persist_runtime_index_drop(
9753        &self,
9754        collection: &str,
9755        name: &str,
9756    ) -> RedDBResult<()> {
9757        let store = self.inner.db.store();
9758        let _ = store.get_or_create_collection(RUNTIME_INDEX_REGISTRY_COLLECTION);
9759        let entity = crate::storage::UnifiedEntity::new(
9760            crate::storage::EntityId::new(0),
9761            crate::storage::EntityKind::TableRow {
9762                table: std::sync::Arc::from(RUNTIME_INDEX_REGISTRY_COLLECTION),
9763                row_id: 0,
9764            },
9765            crate::storage::EntityData::Row(crate::storage::RowData {
9766                columns: Vec::new(),
9767                named: Some(
9768                    [
9769                        (
9770                            "collection".to_string(),
9771                            crate::storage::schema::Value::text(collection.to_string()),
9772                        ),
9773                        (
9774                            "name".to_string(),
9775                            crate::storage::schema::Value::text(name.to_string()),
9776                        ),
9777                        (
9778                            "dropped".to_string(),
9779                            crate::storage::schema::Value::Boolean(true),
9780                        ),
9781                    ]
9782                    .into_iter()
9783                    .collect(),
9784                ),
9785                schema: None,
9786            }),
9787        );
9788        store
9789            .insert_auto(RUNTIME_INDEX_REGISTRY_COLLECTION, entity)
9790            .map(|_| ())
9791            .map_err(|err| RedDBError::Internal(format!("{err:?}")))
9792    }
9793
9794    fn rehydrate_runtime_index_registry(&self) -> RedDBResult<()> {
9795        let store = self.inner.db.store();
9796        let Some(manager) = store.get_collection(RUNTIME_INDEX_REGISTRY_COLLECTION) else {
9797            return Ok(());
9798        };
9799        let mut rows = manager.query_all(|_| true);
9800        rows.sort_by_key(|entity| entity.id.raw());
9801
9802        let mut latest = std::collections::HashMap::<
9803            (String, String),
9804            Option<super::index_store::RegisteredIndex>,
9805        >::new();
9806        for entity in rows {
9807            let crate::storage::EntityData::Row(row) = &entity.data else {
9808                continue;
9809            };
9810            let Some(named) = &row.named else {
9811                continue;
9812            };
9813            let Some(collection) = named_text(named, "collection") else {
9814                continue;
9815            };
9816            let Some(name) = named_text(named, "name") else {
9817                continue;
9818            };
9819            let dropped = named_bool(named, "dropped").unwrap_or(false);
9820            let key = (collection.clone(), name.clone());
9821            if dropped {
9822                latest.insert(key, None);
9823                continue;
9824            }
9825            let columns = named_text(named, "columns")
9826                .map(|raw| {
9827                    raw.split('\u{1f}')
9828                        .filter(|part| !part.is_empty())
9829                        .map(str::to_string)
9830                        .collect::<Vec<_>>()
9831                })
9832                .unwrap_or_default();
9833            let resolution = named_i64(named, "resolution")
9834                .and_then(|v| u8::try_from(v).ok())
9835                .unwrap_or(0);
9836            let Some(method) = named_text(named, "method")
9837                .and_then(|raw| index_method_kind_from_str(&raw, resolution))
9838            else {
9839                continue;
9840            };
9841            latest.insert(
9842                key,
9843                Some(super::index_store::RegisteredIndex {
9844                    name,
9845                    collection,
9846                    columns,
9847                    method,
9848                    unique: named_bool(named, "unique").unwrap_or(false),
9849                }),
9850            );
9851        }
9852
9853        for index in latest.into_values().flatten() {
9854            let Some(manager) = store.get_collection(&index.collection) else {
9855                continue;
9856            };
9857            let entity_fields = manager
9858                .query_all(|entity| {
9859                    matches!(entity.kind, crate::storage::EntityKind::TableRow { .. })
9860                })
9861                .into_iter()
9862                .map(|entity| (entity.id, table_row_index_fields(&entity)))
9863                .collect::<Vec<_>>();
9864            self.inner
9865                .index_store
9866                .create_index(
9867                    &index.name,
9868                    &index.collection,
9869                    &index.columns,
9870                    index.method,
9871                    index.unique,
9872                    &entity_fields,
9873                )
9874                .map_err(RedDBError::Internal)?;
9875            self.inner.index_store.register(index);
9876        }
9877        self.invalidate_plan_cache();
9878        Ok(())
9879    }
9880
9881    /// Own-tx xids (parent + open/released savepoints) for the current
9882    /// connection. Transports + tests that build a `SnapshotContext`
9883    /// manually (outside the `execute_query` scope) need this set so
9884    /// the writer's own uncommitted tuples stay visible to self.
9885    pub fn current_txn_own_xids(
9886        &self,
9887    ) -> std::collections::HashSet<crate::storage::transaction::snapshot::Xid> {
9888        let mut set = std::collections::HashSet::new();
9889        if let Some(ctx) = self.inner.tx_contexts.read().get(&current_connection_id()) {
9890            set.insert(ctx.xid);
9891            for (_, sub) in &ctx.savepoints {
9892                set.insert(*sub);
9893            }
9894            for sub in &ctx.released_sub_xids {
9895                set.insert(*sub);
9896            }
9897        }
9898        set
9899    }
9900
9901    /// Access the shared `ForeignTableRegistry` (Phase 3.2 PG parity).
9902    ///
9903    /// Callers use this to check whether a table name is a registered
9904    /// foreign table (`registry.is_foreign_table(name)`) and, if so, to
9905    /// scan it (`registry.scan(name)`). The read-path rewriter consults
9906    /// this before dispatching into native-collection lookup.
9907    pub fn foreign_tables(&self) -> Arc<crate::storage::fdw::ForeignTableRegistry> {
9908        Arc::clone(&self.inner.foreign_tables)
9909    }
9910
9911    /// Is Row-Level Security enabled for this table? (Phase 2.5 PG parity)
9912    pub fn is_rls_enabled(&self, table: &str) -> bool {
9913        self.inner.rls_enabled_tables.read().contains(table)
9914    }
9915
9916    /// Collect the USING predicates that apply to this `(table, role, action)`.
9917    ///
9918    /// Returned filters should be OR-combined (a row passes RLS when *any*
9919    /// matching policy accepts it) and then AND-ed into the query's WHERE.
9920    /// When the table has RLS disabled this returns an empty Vec — callers
9921    /// can fast-path back to the unfiltered read.
9922    pub fn matching_rls_policies(
9923        &self,
9924        table: &str,
9925        role: Option<&str>,
9926        action: crate::storage::query::ast::PolicyAction,
9927    ) -> Vec<crate::storage::query::ast::Filter> {
9928        // Default kind = Table preserves the pre-Phase-2.5.5 behaviour:
9929        // callers that don't name a kind only see Table-scoped
9930        // policies (which is what execute SELECT / UPDATE / DELETE
9931        // expect).
9932        self.matching_rls_policies_for_kind(
9933            table,
9934            role,
9935            action,
9936            crate::storage::query::ast::PolicyTargetKind::Table,
9937        )
9938    }
9939
9940    /// Kind-aware variant used by cross-model scans (Phase 2.5.5).
9941    ///
9942    /// Graph scans request `Nodes` / `Edges`, vector ANN requests
9943    /// `Vectors`, queue consumers request `Messages`, and timeseries
9944    /// range scans request `Points`. Policies tagged with a
9945    /// different kind are skipped so a graph-scoped policy doesn't
9946    /// accidentally gate a table SELECT on the same collection.
9947    pub fn matching_rls_policies_for_kind(
9948        &self,
9949        table: &str,
9950        role: Option<&str>,
9951        action: crate::storage::query::ast::PolicyAction,
9952        kind: crate::storage::query::ast::PolicyTargetKind,
9953    ) -> Vec<crate::storage::query::ast::Filter> {
9954        if !self.is_rls_enabled(table) {
9955            return Vec::new();
9956        }
9957        let policies = self.inner.rls_policies.read();
9958        policies
9959            .iter()
9960            .filter_map(|((t, _), p)| {
9961                if t != table {
9962                    return None;
9963                }
9964                // Kind gate — Table policies also apply to every
9965                // other kind *iff* the policy predicate evaluates
9966                // against entity fields that exist uniformly; the
9967                // caller's kind filter is the stricter check, so
9968                // match literally. Auto-tenancy policies stamp
9969                // Table and the caller passes the concrete kind —
9970                // we allow Table policies to apply cross-kind for
9971                // backwards compat.
9972                if p.target_kind != kind
9973                    && p.target_kind != crate::storage::query::ast::PolicyTargetKind::Table
9974                {
9975                    return None;
9976                }
9977                // Action gate — `None` means "ALL" actions.
9978                if let Some(a) = p.action {
9979                    if a != action {
9980                        return None;
9981                    }
9982                }
9983                // Role gate — `None` means "any role".
9984                if let Some(p_role) = p.role.as_deref() {
9985                    match role {
9986                        Some(r) if r == p_role => {}
9987                        _ => return None,
9988                    }
9989                }
9990                Some((*p.using).clone())
9991            })
9992            .collect()
9993    }
9994
9995    pub(crate) fn refresh_table_planner_stats(&self, table: &str) {
9996        let store = self.inner.db.store();
9997        if let Some(stats) =
9998            crate::storage::query::planner::stats_catalog::analyze_collection(store.as_ref(), table)
9999        {
10000            crate::storage::query::planner::stats_catalog::persist_table_stats(
10001                store.as_ref(),
10002                &stats,
10003            );
10004        } else {
10005            crate::storage::query::planner::stats_catalog::clear_table_stats(store.as_ref(), table);
10006        }
10007        self.invalidate_plan_cache();
10008    }
10009
10010    pub(crate) fn note_table_write(&self, table: &str) {
10011        // Skip the write lock when the table is already marked
10012        // dirty. With single-row UPDATEs in a loop this used to
10013        // grab the planner_dirty_tables write lock N times even
10014        // though the first call already flipped the flag.
10015        let already_dirty = self.inner.planner_dirty_tables.read().contains(table);
10016        if !already_dirty {
10017            self.inner
10018                .planner_dirty_tables
10019                .write()
10020                .insert(table.to_string());
10021        }
10022        self.invalidate_result_cache_for_table(table);
10023    }
10024
10025    /// Wrap the planner's `RuntimeQueryExplain` as rows on a
10026    /// `RuntimeQueryResult` so callers over the SQL interface see the
10027    /// plan tree in the same shape a SELECT produces.
10028    ///
10029    /// Columns: `op`, `source`, `est_rows`, `est_cost`, `depth`.
10030    /// Nodes are walked depth-first; `depth` counts from 0 at the
10031    /// root so a text renderer can indent without re-walking.
10032    fn explain_as_rows(&self, raw_query: &str, inner_sql: &str) -> RedDBResult<RuntimeQueryResult> {
10033        let explain = self.explain_query(inner_sql)?;
10034
10035        let columns = vec![
10036            "op".to_string(),
10037            "source".to_string(),
10038            "est_rows".to_string(),
10039            "est_cost".to_string(),
10040            "depth".to_string(),
10041        ];
10042
10043        let mut records: Vec<crate::storage::query::unified::UnifiedRecord> = Vec::new();
10044
10045        // Prepend `CteScan` markers when the query carried a leading
10046        // WITH clause. The CTE bodies are already inlined into the
10047        // main plan tree, but operators reading EXPLAIN need to see
10048        // which named CTEs were resolved — without this row the plan
10049        // would look indistinguishable from a hand-inlined query.
10050        for name in &explain.cte_materializations {
10051            use std::sync::Arc;
10052            let mut rec = crate::storage::query::unified::UnifiedRecord::default();
10053            rec.set_arc(Arc::from("op"), Value::text("CteScan".to_string()));
10054            rec.set_arc(Arc::from("source"), Value::text(name.clone()));
10055            rec.set_arc(Arc::from("est_rows"), Value::Float(0.0));
10056            rec.set_arc(Arc::from("est_cost"), Value::Float(0.0));
10057            rec.set_arc(Arc::from("depth"), Value::Integer(0));
10058            records.push(rec);
10059        }
10060
10061        walk_plan_node(&explain.logical_plan.root, 0, &mut records);
10062
10063        let result = crate::storage::query::unified::UnifiedResult {
10064            columns,
10065            records,
10066            stats: Default::default(),
10067            pre_serialized_json: None,
10068        };
10069
10070        Ok(RuntimeQueryResult {
10071            query: raw_query.to_string(),
10072            mode: explain.mode,
10073            statement: "explain",
10074            engine: "runtime-explain",
10075            result,
10076            affected_rows: 0,
10077            statement_type: "select",
10078            bookmark: None,
10079        })
10080    }
10081
10082    // -----------------------------------------------------------------
10083    // Granular RBAC — privilege gate + GRANT/REVOKE/ALTER USER dispatch
10084    // -----------------------------------------------------------------
10085
10086    /// Project a `QueryExpr` to the (action, resource) pair the
10087    /// privilege engine cares about. Returns `Ok(())` for statements
10088    /// that don't touch user data (transaction control, SHOW, SET, etc.).
10089    pub(crate) fn check_query_privilege(
10090        &self,
10091        expr: &crate::storage::query::ast::QueryExpr,
10092    ) -> Result<(), String> {
10093        use crate::auth::privileges::{Action, AuthzContext, Resource};
10094        use crate::auth::UserId;
10095        use crate::storage::query::ast::QueryExpr;
10096
10097        // No auth store wired (embedded mode / fresh DB / tests) → bypass.
10098        // The bootstrap path itself goes through `execute_query` so this
10099        // is the only sensible default; once auth is wired, the gate
10100        // becomes active.
10101        let auth_store = match self.inner.auth_store.read().clone() {
10102            Some(s) => s,
10103            None => return Ok(()),
10104        };
10105
10106        // Resolve principal + role from the thread-local identity.
10107        // Anonymous (no identity) is allowed to read the bootstrap path
10108        // only when auth_store says so; we treat missing identity as
10109        // platform-admin-equivalent here so embedded test harnesses
10110        // continue to work without setting an identity.
10111        let (username, role) = match current_auth_identity() {
10112            Some(p) => p,
10113            None => return Ok(()),
10114        };
10115        let tenant = current_tenant();
10116
10117        let ctx = AuthzContext {
10118            principal: &username,
10119            effective_role: role,
10120            tenant: tenant.as_deref(),
10121        };
10122        let principal_id = UserId::from_parts(tenant.as_deref(), &username);
10123
10124        // Map QueryExpr → (Action, Resource).
10125        let (action, resource) = match expr {
10126            QueryExpr::Table(t) => (Action::Select, Resource::table_from_name(&t.table)),
10127            QueryExpr::RankOf(_) | QueryExpr::ApproxRankOf(_) | QueryExpr::RankRange(_) => {
10128                (Action::Select, Resource::Database)
10129            }
10130            QueryExpr::QueueSelect(q) => {
10131                return self.check_queue_op_privilege(
10132                    &auth_store,
10133                    &principal_id,
10134                    role,
10135                    tenant.as_deref(),
10136                    "queue:peek",
10137                    &q.queue,
10138                );
10139            }
10140            QueryExpr::QueueCommand(cmd) => {
10141                use crate::storage::query::ast::QueueCommand;
10142                let (queue, action_verb) = match cmd {
10143                    QueueCommand::Push { queue, .. } => (queue.as_str(), "queue:enqueue"),
10144                    QueueCommand::Pop { queue, .. }
10145                    | QueueCommand::GroupRead { queue, .. }
10146                    | QueueCommand::Claim { queue, .. } => (queue.as_str(), "queue:read"),
10147                    QueueCommand::Peek { queue, .. }
10148                    | QueueCommand::Len { queue }
10149                    | QueueCommand::Pending { queue, .. } => (queue.as_str(), "queue:peek"),
10150                    QueueCommand::Ack { queue, .. } => (queue.as_str(), "queue:ack"),
10151                    QueueCommand::Nack {
10152                        queue, delay_ms, ..
10153                    } => {
10154                        // Per-failure retry overrides re-shape retry
10155                        // behaviour for everyone draining the queue and
10156                        // gate on the dedicated `queue:retry` verb so
10157                        // operators can grant base NACK without granting
10158                        // the override capability.
10159                        let verb = if delay_ms.is_some() {
10160                            "queue:retry"
10161                        } else {
10162                            "queue:nack"
10163                        };
10164                        (queue.as_str(), verb)
10165                    }
10166                    QueueCommand::Purge { queue } => (queue.as_str(), "queue:purge"),
10167                    // `GroupCreate` is part of the consumer-setup
10168                    // surface — read-side, never destructive.
10169                    QueueCommand::GroupCreate { queue, .. } => (queue.as_str(), "queue:read"),
10170                    QueueCommand::Move { source, .. } => (source.as_str(), "queue:dlq:move"),
10171                };
10172                return self.check_queue_op_privilege(
10173                    &auth_store,
10174                    &principal_id,
10175                    role,
10176                    tenant.as_deref(),
10177                    action_verb,
10178                    queue,
10179                );
10180            }
10181            QueryExpr::Graph(g) => {
10182                // MATCH … RETURN is the explorer's pattern-traversal
10183                // surface — gate on `graph:traverse` (#757).
10184                self.check_graph_op_privilege(
10185                    &auth_store,
10186                    &principal_id,
10187                    role,
10188                    tenant.as_deref(),
10189                    "graph:traverse",
10190                )?;
10191                if auth_store.iam_authorization_enabled() {
10192                    self.check_graph_property_projection_privilege(
10193                        &auth_store,
10194                        &principal_id,
10195                        role,
10196                        tenant.as_deref(),
10197                        g,
10198                    )?;
10199                    return Ok(());
10200                }
10201                return Ok(());
10202            }
10203            QueryExpr::Path(_) => {
10204                // PATH FROM … TO … is a path-traversal query — gates
10205                // on `graph:traverse` like neighborhood/shortest-path
10206                // (#757).
10207                return self.check_graph_op_privilege(
10208                    &auth_store,
10209                    &principal_id,
10210                    role,
10211                    tenant.as_deref(),
10212                    "graph:traverse",
10213                );
10214            }
10215            QueryExpr::GraphCommand(cmd) => {
10216                use crate::storage::query::ast::GraphCommand;
10217                let action_verb = match cmd {
10218                    // Metadata / property reads.
10219                    GraphCommand::Properties { .. } => "graph:read",
10220                    // Traversal / pattern-walk surface.
10221                    GraphCommand::Neighborhood { .. }
10222                    | GraphCommand::Traverse { .. }
10223                    | GraphCommand::ShortestPath { .. } => "graph:traverse",
10224                    // Analytics algorithms — expensive enough that Red
10225                    // UI needs to gate the runner independently of
10226                    // ordinary traversal.
10227                    GraphCommand::Centrality { .. }
10228                    | GraphCommand::Community { .. }
10229                    | GraphCommand::Components { .. }
10230                    | GraphCommand::Cycles { .. }
10231                    | GraphCommand::Clustering
10232                    | GraphCommand::TopologicalSort => "graph:algorithm:run",
10233                };
10234                return self.check_graph_op_privilege(
10235                    &auth_store,
10236                    &principal_id,
10237                    role,
10238                    tenant.as_deref(),
10239                    action_verb,
10240                );
10241            }
10242            QueryExpr::Vector(v) => {
10243                if auth_store.iam_authorization_enabled() {
10244                    self.check_vector_op_privilege(
10245                        &auth_store,
10246                        &principal_id,
10247                        role,
10248                        tenant.as_deref(),
10249                        "vector:search",
10250                        &v.collection,
10251                    )?;
10252                    self.check_table_like_column_projection_privilege(
10253                        &auth_store,
10254                        &principal_id,
10255                        role,
10256                        tenant.as_deref(),
10257                        &v.collection,
10258                        &["content".to_string()],
10259                    )?;
10260                    return Ok(());
10261                }
10262                return Ok(());
10263            }
10264            QueryExpr::SearchCommand(cmd) => {
10265                use crate::storage::query::ast::SearchCommand;
10266                if auth_store.iam_authorization_enabled() {
10267                    // `SEARCH SIMILAR [..] COLLECTION <c>` and `SEARCH
10268                    // HYBRID ... COLLECTION <c>` are the same UI
10269                    // affordances as `VECTOR SEARCH` / hybrid joins —
10270                    // Red UI must see the same `vector:search` envelope
10271                    // so a single toolbar grant is sufficient.
10272                    let collection = match cmd {
10273                        SearchCommand::Similar { collection, .. }
10274                        | SearchCommand::Hybrid { collection, .. } => Some(collection.as_str()),
10275                        _ => None,
10276                    };
10277                    if let Some(c) = collection {
10278                        self.check_vector_op_privilege(
10279                            &auth_store,
10280                            &principal_id,
10281                            role,
10282                            tenant.as_deref(),
10283                            "vector:search",
10284                            c,
10285                        )?;
10286                        return Ok(());
10287                    }
10288                }
10289                return Ok(());
10290            }
10291            QueryExpr::Hybrid(h) => {
10292                if auth_store.iam_authorization_enabled() {
10293                    // The vector half of a hybrid search is gated under
10294                    // the same `vector:search` verb as a standalone
10295                    // VECTOR SEARCH — Red UI's hybrid-search toolbar
10296                    // must surface the same UI-safe denial envelope
10297                    // when the principal lacks the grant. The
10298                    // structured half is dispatched to its own gate via
10299                    // the inner query during execution.
10300                    self.check_vector_op_privilege(
10301                        &auth_store,
10302                        &principal_id,
10303                        role,
10304                        tenant.as_deref(),
10305                        "vector:search",
10306                        &h.vector.collection,
10307                    )?;
10308                    return Ok(());
10309                }
10310                return Ok(());
10311            }
10312            QueryExpr::Insert(i) => (Action::Insert, Resource::table_from_name(&i.table)),
10313            QueryExpr::Update(u) => (Action::Update, Resource::table_from_name(&u.table)),
10314            QueryExpr::Delete(d) => (Action::Delete, Resource::table_from_name(&d.table)),
10315            // Joins inherit the read privilege from any constituent
10316            // table — for now we emit a single Select on the database
10317            // (admins bypass; non-admins need a Database/Schema grant).
10318            QueryExpr::Join(_) => (Action::Select, Resource::Database),
10319            // GRANT / REVOKE / USER DDL are authority statements;
10320            // require Admin (the helper methods enforce).
10321            QueryExpr::Grant(_)
10322            | QueryExpr::Revoke(_)
10323            | QueryExpr::AlterUser(_)
10324            | QueryExpr::CreateUser(_) => {
10325                return if role == crate::auth::Role::Admin {
10326                    Ok(())
10327                } else {
10328                    Err(format!(
10329                        "principal=`{}` role=`{:?}` cannot issue ACL/auth DDL",
10330                        username, role
10331                    ))
10332                };
10333            }
10334            QueryExpr::CreateIamPolicy { id, .. } => {
10335                return self.check_policy_management_privilege(
10336                    &auth_store,
10337                    &principal_id,
10338                    role,
10339                    tenant.as_deref(),
10340                    "policy:put",
10341                    "policy",
10342                    id,
10343                );
10344            }
10345            QueryExpr::DropIamPolicy { id } => {
10346                return self.check_policy_management_privilege(
10347                    &auth_store,
10348                    &principal_id,
10349                    role,
10350                    tenant.as_deref(),
10351                    "policy:drop",
10352                    "policy",
10353                    id,
10354                );
10355            }
10356            QueryExpr::AttachPolicy { policy_id, .. } => {
10357                return self.check_policy_management_privilege(
10358                    &auth_store,
10359                    &principal_id,
10360                    role,
10361                    tenant.as_deref(),
10362                    "policy:attach",
10363                    "policy",
10364                    policy_id,
10365                );
10366            }
10367            QueryExpr::DetachPolicy { policy_id, .. } => {
10368                return self.check_policy_management_privilege(
10369                    &auth_store,
10370                    &principal_id,
10371                    role,
10372                    tenant.as_deref(),
10373                    "policy:detach",
10374                    "policy",
10375                    policy_id,
10376                );
10377            }
10378            QueryExpr::ShowPolicies { .. } | QueryExpr::ShowEffectivePermissions { .. } => {
10379                return Ok(());
10380            }
10381            QueryExpr::SimulatePolicy { .. } => {
10382                return self.check_policy_management_privilege(
10383                    &auth_store,
10384                    &principal_id,
10385                    role,
10386                    tenant.as_deref(),
10387                    "policy:simulate",
10388                    "policy",
10389                    "*",
10390                );
10391            }
10392            QueryExpr::LintPolicy { .. } => {
10393                // Linting is a read-only inspection — gate it like
10394                // simulate (policy management role).
10395                return self.check_policy_management_privilege(
10396                    &auth_store,
10397                    &principal_id,
10398                    role,
10399                    tenant.as_deref(),
10400                    "policy:simulate",
10401                    "policy",
10402                    "*",
10403                );
10404            }
10405            QueryExpr::MigratePolicyMode { dry_run, .. } => {
10406                // DRY RUN is a pre-flight inspection (policy:simulate).
10407                // The actual mode flip is a privileged mutation under
10408                // the policy:put action (it persists a new enforcement
10409                // mode to the vault KV through `set_enforcement_mode`).
10410                let action = if *dry_run {
10411                    "policy:simulate"
10412                } else {
10413                    "policy:put"
10414                };
10415                return self.check_policy_management_privilege(
10416                    &auth_store,
10417                    &principal_id,
10418                    role,
10419                    tenant.as_deref(),
10420                    action,
10421                    "policy",
10422                    "*",
10423                );
10424            }
10425            // DROP and TRUNCATE — Write-role gate + per-collection IAM policy
10426            // when IAM mode is active. Other DDL stays role-only for now.
10427            QueryExpr::DropTable(q) => {
10428                return self.check_ddl_collection_privilege(
10429                    &auth_store,
10430                    &principal_id,
10431                    role,
10432                    tenant.as_deref(),
10433                    &username,
10434                    "drop",
10435                    &q.name,
10436                );
10437            }
10438            QueryExpr::DropGraph(q) => {
10439                return self.check_ddl_collection_privilege(
10440                    &auth_store,
10441                    &principal_id,
10442                    role,
10443                    tenant.as_deref(),
10444                    &username,
10445                    "drop",
10446                    &q.name,
10447                );
10448            }
10449            QueryExpr::DropVector(q) => {
10450                return self.check_ddl_collection_privilege(
10451                    &auth_store,
10452                    &principal_id,
10453                    role,
10454                    tenant.as_deref(),
10455                    &username,
10456                    "drop",
10457                    &q.name,
10458                );
10459            }
10460            QueryExpr::DropDocument(q) => {
10461                return self.check_ddl_collection_privilege(
10462                    &auth_store,
10463                    &principal_id,
10464                    role,
10465                    tenant.as_deref(),
10466                    &username,
10467                    "drop",
10468                    &q.name,
10469                );
10470            }
10471            QueryExpr::DropKv(q) => {
10472                return self.check_ddl_collection_privilege(
10473                    &auth_store,
10474                    &principal_id,
10475                    role,
10476                    tenant.as_deref(),
10477                    &username,
10478                    "drop",
10479                    &q.name,
10480                );
10481            }
10482            QueryExpr::DropCollection(q) => {
10483                return self.check_ddl_collection_privilege(
10484                    &auth_store,
10485                    &principal_id,
10486                    role,
10487                    tenant.as_deref(),
10488                    &username,
10489                    "drop",
10490                    &q.name,
10491                );
10492            }
10493            QueryExpr::Truncate(q) => {
10494                return self.check_ddl_collection_privilege(
10495                    &auth_store,
10496                    &principal_id,
10497                    role,
10498                    tenant.as_deref(),
10499                    &username,
10500                    "truncate",
10501                    &q.name,
10502                );
10503            }
10504            // Remaining DDL (#753) — hybrid policy-aware gate. Specific
10505            // create/alter/drop verbs gate operations with a clear
10506            // per-collection target so Red UI can author fine-grained
10507            // policies (`create on collection:users`). Namespace-level
10508            // and grouped DDL fall back to broader `schema:admin` /
10509            // `schema:write` verbs against a `schema:<name>` resource.
10510            // All branches share the [`check_ddl_object_privilege`]
10511            // helper so allows / denies produce the same structured
10512            // "principal=… action=… resource=<kind>:<name> denied by
10513            // IAM policy" reason the Red UI security read contracts
10514            // (#740) already render.
10515            QueryExpr::CreateTable(q) => {
10516                return self.check_ddl_object_privilege(
10517                    &auth_store,
10518                    &principal_id,
10519                    role,
10520                    tenant.as_deref(),
10521                    &username,
10522                    "create",
10523                    "collection",
10524                    &q.name,
10525                    crate::auth::Role::Write,
10526                );
10527            }
10528            QueryExpr::CreateCollection(q) => {
10529                return self.check_ddl_object_privilege(
10530                    &auth_store,
10531                    &principal_id,
10532                    role,
10533                    tenant.as_deref(),
10534                    &username,
10535                    "create",
10536                    "collection",
10537                    &q.name,
10538                    crate::auth::Role::Write,
10539                );
10540            }
10541            QueryExpr::CreateVector(q) => {
10542                return self.check_ddl_object_privilege(
10543                    &auth_store,
10544                    &principal_id,
10545                    role,
10546                    tenant.as_deref(),
10547                    &username,
10548                    "create",
10549                    "collection",
10550                    &q.name,
10551                    crate::auth::Role::Write,
10552                );
10553            }
10554            QueryExpr::AlterTable(q) => {
10555                return self.check_ddl_object_privilege(
10556                    &auth_store,
10557                    &principal_id,
10558                    role,
10559                    tenant.as_deref(),
10560                    &username,
10561                    "alter",
10562                    "collection",
10563                    &q.name,
10564                    crate::auth::Role::Write,
10565                );
10566            }
10567            QueryExpr::CreateIndex(q) => {
10568                return self.check_ddl_object_privilege(
10569                    &auth_store,
10570                    &principal_id,
10571                    role,
10572                    tenant.as_deref(),
10573                    &username,
10574                    "create",
10575                    "collection",
10576                    &q.table,
10577                    crate::auth::Role::Write,
10578                );
10579            }
10580            QueryExpr::DropIndex(q) => {
10581                return self.check_ddl_object_privilege(
10582                    &auth_store,
10583                    &principal_id,
10584                    role,
10585                    tenant.as_deref(),
10586                    &username,
10587                    "drop",
10588                    "collection",
10589                    &q.table,
10590                    crate::auth::Role::Write,
10591                );
10592            }
10593            QueryExpr::CreateSchema(q) => {
10594                return self.check_ddl_object_privilege(
10595                    &auth_store,
10596                    &principal_id,
10597                    role,
10598                    tenant.as_deref(),
10599                    &username,
10600                    "schema:admin",
10601                    "schema",
10602                    &q.name,
10603                    crate::auth::Role::Admin,
10604                );
10605            }
10606            QueryExpr::DropSchema(q) => {
10607                return self.check_ddl_object_privilege(
10608                    &auth_store,
10609                    &principal_id,
10610                    role,
10611                    tenant.as_deref(),
10612                    &username,
10613                    "schema:admin",
10614                    "schema",
10615                    &q.name,
10616                    crate::auth::Role::Admin,
10617                );
10618            }
10619            QueryExpr::CreateSequence(q) => {
10620                return self.check_ddl_object_privilege(
10621                    &auth_store,
10622                    &principal_id,
10623                    role,
10624                    tenant.as_deref(),
10625                    &username,
10626                    "create",
10627                    "collection",
10628                    &q.name,
10629                    crate::auth::Role::Write,
10630                );
10631            }
10632            QueryExpr::DropSequence(q) => {
10633                return self.check_ddl_object_privilege(
10634                    &auth_store,
10635                    &principal_id,
10636                    role,
10637                    tenant.as_deref(),
10638                    &username,
10639                    "drop",
10640                    "collection",
10641                    &q.name,
10642                    crate::auth::Role::Write,
10643                );
10644            }
10645            QueryExpr::CreateView(q) => {
10646                return self.check_ddl_object_privilege(
10647                    &auth_store,
10648                    &principal_id,
10649                    role,
10650                    tenant.as_deref(),
10651                    &username,
10652                    "create",
10653                    "collection",
10654                    &q.name,
10655                    crate::auth::Role::Write,
10656                );
10657            }
10658            QueryExpr::DropView(q) => {
10659                return self.check_ddl_object_privilege(
10660                    &auth_store,
10661                    &principal_id,
10662                    role,
10663                    tenant.as_deref(),
10664                    &username,
10665                    "drop",
10666                    "collection",
10667                    &q.name,
10668                    crate::auth::Role::Write,
10669                );
10670            }
10671            QueryExpr::RefreshMaterializedView(q) => {
10672                return self.check_ddl_object_privilege(
10673                    &auth_store,
10674                    &principal_id,
10675                    role,
10676                    tenant.as_deref(),
10677                    &username,
10678                    "alter",
10679                    "collection",
10680                    &q.name,
10681                    crate::auth::Role::Write,
10682                );
10683            }
10684            QueryExpr::CreatePolicy(q) => {
10685                return self.check_ddl_object_privilege(
10686                    &auth_store,
10687                    &principal_id,
10688                    role,
10689                    tenant.as_deref(),
10690                    &username,
10691                    "create",
10692                    "collection",
10693                    &q.table,
10694                    crate::auth::Role::Write,
10695                );
10696            }
10697            QueryExpr::DropPolicy(q) => {
10698                return self.check_ddl_object_privilege(
10699                    &auth_store,
10700                    &principal_id,
10701                    role,
10702                    tenant.as_deref(),
10703                    &username,
10704                    "drop",
10705                    "collection",
10706                    &q.table,
10707                    crate::auth::Role::Write,
10708                );
10709            }
10710            QueryExpr::CreateServer(q) => {
10711                return self.check_ddl_object_privilege(
10712                    &auth_store,
10713                    &principal_id,
10714                    role,
10715                    tenant.as_deref(),
10716                    &username,
10717                    "schema:admin",
10718                    "schema",
10719                    &q.name,
10720                    crate::auth::Role::Admin,
10721                );
10722            }
10723            QueryExpr::DropServer(q) => {
10724                return self.check_ddl_object_privilege(
10725                    &auth_store,
10726                    &principal_id,
10727                    role,
10728                    tenant.as_deref(),
10729                    &username,
10730                    "schema:admin",
10731                    "schema",
10732                    &q.name,
10733                    crate::auth::Role::Admin,
10734                );
10735            }
10736            QueryExpr::CreateForeignTable(q) => {
10737                return self.check_ddl_object_privilege(
10738                    &auth_store,
10739                    &principal_id,
10740                    role,
10741                    tenant.as_deref(),
10742                    &username,
10743                    "schema:write",
10744                    "schema",
10745                    &q.name,
10746                    crate::auth::Role::Write,
10747                );
10748            }
10749            QueryExpr::DropForeignTable(q) => {
10750                return self.check_ddl_object_privilege(
10751                    &auth_store,
10752                    &principal_id,
10753                    role,
10754                    tenant.as_deref(),
10755                    &username,
10756                    "schema:write",
10757                    "schema",
10758                    &q.name,
10759                    crate::auth::Role::Write,
10760                );
10761            }
10762            QueryExpr::CreateTimeSeries(q) => {
10763                return self.check_ddl_object_privilege(
10764                    &auth_store,
10765                    &principal_id,
10766                    role,
10767                    tenant.as_deref(),
10768                    &username,
10769                    "create",
10770                    "collection",
10771                    &q.name,
10772                    crate::auth::Role::Write,
10773                );
10774            }
10775            QueryExpr::CreateMetric(q) => {
10776                return self.check_ddl_object_privilege(
10777                    &auth_store,
10778                    &principal_id,
10779                    role,
10780                    tenant.as_deref(),
10781                    &username,
10782                    "create",
10783                    "collection",
10784                    &q.path,
10785                    crate::auth::Role::Write,
10786                );
10787            }
10788            QueryExpr::AlterMetric(q) => {
10789                return self.check_ddl_object_privilege(
10790                    &auth_store,
10791                    &principal_id,
10792                    role,
10793                    tenant.as_deref(),
10794                    &username,
10795                    "alter",
10796                    "collection",
10797                    &q.path,
10798                    crate::auth::Role::Write,
10799                );
10800            }
10801            QueryExpr::CreateSlo(q) => {
10802                return self.check_ddl_object_privilege(
10803                    &auth_store,
10804                    &principal_id,
10805                    role,
10806                    tenant.as_deref(),
10807                    &username,
10808                    "create",
10809                    "collection",
10810                    &q.path,
10811                    crate::auth::Role::Write,
10812                );
10813            }
10814            QueryExpr::DropTimeSeries(q) => {
10815                return self.check_ddl_object_privilege(
10816                    &auth_store,
10817                    &principal_id,
10818                    role,
10819                    tenant.as_deref(),
10820                    &username,
10821                    "drop",
10822                    "collection",
10823                    &q.name,
10824                    crate::auth::Role::Write,
10825                );
10826            }
10827            QueryExpr::CreateQueue(q) => {
10828                return self.check_ddl_object_privilege(
10829                    &auth_store,
10830                    &principal_id,
10831                    role,
10832                    tenant.as_deref(),
10833                    &username,
10834                    "create",
10835                    "collection",
10836                    &q.name,
10837                    crate::auth::Role::Write,
10838                );
10839            }
10840            QueryExpr::AlterQueue(q) => {
10841                return self.check_ddl_object_privilege(
10842                    &auth_store,
10843                    &principal_id,
10844                    role,
10845                    tenant.as_deref(),
10846                    &username,
10847                    "alter",
10848                    "collection",
10849                    &q.name,
10850                    crate::auth::Role::Write,
10851                );
10852            }
10853            QueryExpr::DropQueue(q) => {
10854                return self.check_ddl_object_privilege(
10855                    &auth_store,
10856                    &principal_id,
10857                    role,
10858                    tenant.as_deref(),
10859                    &username,
10860                    "drop",
10861                    "collection",
10862                    &q.name,
10863                    crate::auth::Role::Write,
10864                );
10865            }
10866            QueryExpr::CreateTree(q) => {
10867                return self.check_ddl_object_privilege(
10868                    &auth_store,
10869                    &principal_id,
10870                    role,
10871                    tenant.as_deref(),
10872                    &username,
10873                    "create",
10874                    "collection",
10875                    &q.collection,
10876                    crate::auth::Role::Write,
10877                );
10878            }
10879            QueryExpr::DropTree(q) => {
10880                return self.check_ddl_object_privilege(
10881                    &auth_store,
10882                    &principal_id,
10883                    role,
10884                    tenant.as_deref(),
10885                    &username,
10886                    "drop",
10887                    "collection",
10888                    &q.collection,
10889                    crate::auth::Role::Write,
10890                );
10891            }
10892            // Migration DDL — CREATE MIGRATION is grouped DDL on the
10893            // schema namespace; uses the `schema:write` fallback verb
10894            // (no obvious per-collection target).
10895            QueryExpr::CreateMigration(q) => {
10896                return self.check_ddl_object_privilege(
10897                    &auth_store,
10898                    &principal_id,
10899                    role,
10900                    tenant.as_deref(),
10901                    &username,
10902                    "schema:write",
10903                    "schema",
10904                    &q.name,
10905                    crate::auth::Role::Write,
10906                );
10907            }
10908            // APPLY / ROLLBACK change data and schema — require Admin.
10909            QueryExpr::ApplyMigration(_) | QueryExpr::RollbackMigration(_) => {
10910                return if role == crate::auth::Role::Admin {
10911                    Ok(())
10912                } else {
10913                    Err(format!(
10914                        "principal=`{}` role=`{:?}` cannot issue APPLY/ROLLBACK MIGRATION",
10915                        username, role
10916                    ))
10917                };
10918            }
10919            // EXPLAIN MIGRATION is read-only — any authenticated principal.
10920            QueryExpr::ExplainMigration(_) => return Ok(()),
10921            // Everything else (SET, SHOW, transaction control, graph
10922            // commands, queue/tree commands, MaintenanceCommand …)
10923            // is allowed for any authenticated principal.
10924            _ => return Ok(()),
10925        };
10926
10927        if auth_store.iam_authorization_enabled() {
10928            let iam_action = legacy_action_to_iam(action);
10929            let iam_resource = legacy_resource_to_iam(&resource, tenant.as_deref());
10930            let iam_ctx = runtime_iam_context(role, tenant.as_deref());
10931            if !auth_store.check_policy_authz_with_role(
10932                &principal_id,
10933                iam_action,
10934                &iam_resource,
10935                &iam_ctx,
10936                role,
10937            ) {
10938                return Err(format!(
10939                    "principal=`{}` action=`{}` resource=`{}:{}` denied by IAM policy",
10940                    username, iam_action, iam_resource.kind, iam_resource.name
10941                ));
10942            }
10943
10944            if let QueryExpr::Table(table) = expr {
10945                self.check_table_column_projection_privilege(
10946                    &auth_store,
10947                    &principal_id,
10948                    &iam_ctx,
10949                    table,
10950                )?;
10951            }
10952
10953            if let QueryExpr::Update(update) = expr {
10954                let columns = update_set_target_columns(update);
10955                if !columns.is_empty() {
10956                    let request = column_access_request_for_table_update(&update.table, columns);
10957                    let outcome =
10958                        auth_store.check_column_projection_authz(&principal_id, &request, &iam_ctx);
10959                    if let Some(denied) = outcome.first_denied_column() {
10960                        return Err(format!(
10961                            "principal=`{}` action=`{}` resource=`{}:{}` denied by IAM column policy",
10962                            username, iam_action, denied.resource.kind, denied.resource.name
10963                        ));
10964                    }
10965                    if !outcome.allowed() {
10966                        return Err(format!(
10967                            "principal=`{}` action=`{}` resource=`{}:{}` denied by IAM policy",
10968                            username,
10969                            iam_action,
10970                            outcome.table_resource.kind,
10971                            outcome.table_resource.name
10972                        ));
10973                    }
10974                }
10975
10976                if let Some(columns) = update_returning_columns_for_policy(self, update) {
10977                    let request = column_access_request_for_table_select(&update.table, columns);
10978                    let outcome =
10979                        auth_store.check_column_projection_authz(&principal_id, &request, &iam_ctx);
10980                    if let Some(denied) = outcome.first_denied_column() {
10981                        return Err(format!(
10982                            "principal=`{}` action=`select` resource=`{}:{}` denied by IAM column policy",
10983                            username, denied.resource.kind, denied.resource.name
10984                        ));
10985                    }
10986                    if !outcome.allowed() {
10987                        return Err(format!(
10988                            "principal=`{}` action=`select` resource=`{}:{}` denied by IAM policy",
10989                            username, outcome.table_resource.kind, outcome.table_resource.name
10990                        ));
10991                    }
10992                }
10993            }
10994
10995            Ok(())
10996        } else {
10997            auth_store
10998                .check_grant(&ctx, action, &resource)
10999                .map_err(|e| e.to_string())
11000        }
11001    }
11002
11003    fn check_table_column_projection_privilege(
11004        &self,
11005        auth_store: &Arc<crate::auth::store::AuthStore>,
11006        principal: &crate::auth::UserId,
11007        ctx: &crate::auth::policies::EvalContext,
11008        table: &crate::storage::query::ast::TableQuery,
11009    ) -> Result<(), String> {
11010        use crate::auth::{ColumnAccessRequest, ColumnDecisionEffect};
11011
11012        let columns = requested_table_columns_for_policy(table);
11013        if columns.is_empty() {
11014            return Ok(());
11015        }
11016
11017        let request = ColumnAccessRequest::select(table.table.clone(), columns);
11018        let outcome = auth_store.check_column_projection_authz(principal, &request, ctx);
11019        if outcome.allowed() {
11020            return Ok(());
11021        }
11022
11023        if !matches!(
11024            outcome.table_decision,
11025            crate::auth::policies::Decision::Allow { .. }
11026                | crate::auth::policies::Decision::AdminBypass
11027        ) {
11028            return Err(format!(
11029                "principal=`{}` action=`select` resource=`{}:{}` denied by IAM policy",
11030                principal, outcome.table_resource.kind, outcome.table_resource.name
11031            ));
11032        }
11033
11034        let denied = outcome
11035            .first_denied_column()
11036            .filter(|decision| decision.effective == ColumnDecisionEffect::Denied);
11037        match denied {
11038            Some(decision) => Err(format!(
11039                "principal=`{}` action=`select` resource=`{}:{}` denied by IAM policy",
11040                principal, decision.resource.kind, decision.resource.name
11041            )),
11042            None => Ok(()),
11043        }
11044    }
11045
11046    fn check_graph_property_projection_privilege(
11047        &self,
11048        auth_store: &Arc<crate::auth::store::AuthStore>,
11049        principal: &crate::auth::UserId,
11050        role: crate::auth::Role,
11051        tenant: Option<&str>,
11052        query: &crate::storage::query::ast::GraphQuery,
11053    ) -> Result<(), String> {
11054        let columns = explicit_graph_projection_properties(query);
11055        if columns.is_empty() {
11056            return Ok(());
11057        }
11058        self.check_table_like_column_projection_privilege(
11059            auth_store, principal, role, tenant, "graph", &columns,
11060        )
11061    }
11062
11063    fn check_table_like_column_projection_privilege(
11064        &self,
11065        auth_store: &Arc<crate::auth::store::AuthStore>,
11066        principal: &crate::auth::UserId,
11067        role: crate::auth::Role,
11068        tenant: Option<&str>,
11069        table: &str,
11070        columns: &[String],
11071    ) -> Result<(), String> {
11072        let iam_ctx = runtime_iam_context(role, tenant);
11073        let request =
11074            crate::auth::ColumnAccessRequest::select(table.to_string(), columns.iter().cloned());
11075        let outcome = auth_store.check_column_projection_authz(principal, &request, &iam_ctx);
11076        if outcome.allowed() {
11077            return Ok(());
11078        }
11079        let denied = outcome
11080            .first_denied_column()
11081            .map(|d| d.resource.name.clone())
11082            .unwrap_or_else(|| format!("{table}.<unknown>"));
11083        Err(format!(
11084            "principal=`{}` action=`select` resource=`column:{}` denied by IAM policy",
11085            principal, denied
11086        ))
11087    }
11088
11089    fn check_policy_management_privilege(
11090        &self,
11091        auth_store: &Arc<crate::auth::store::AuthStore>,
11092        principal: &crate::auth::UserId,
11093        role: crate::auth::Role,
11094        tenant: Option<&str>,
11095        action: &str,
11096        resource_kind: &str,
11097        resource_name: &str,
11098    ) -> Result<(), String> {
11099        let ctx = runtime_iam_context(role, tenant);
11100
11101        if !auth_store.iam_authorization_enabled() {
11102            return if role == crate::auth::Role::Admin {
11103                Ok(())
11104            } else {
11105                Err(format!(
11106                    "principal=`{}` role=`{:?}` cannot issue ACL/auth DDL",
11107                    principal, role
11108                ))
11109            };
11110        }
11111
11112        if resource_kind == "policy"
11113            && matches!(
11114                action,
11115                "policy:put" | "policy:drop" | "policy:attach" | "policy:detach"
11116            )
11117            && self
11118                .inner
11119                .config_registry
11120                .get_active(resource_name)
11121                .map(|entry| entry.managed)
11122                .unwrap_or(false)
11123        {
11124            return Ok(());
11125        }
11126
11127        let mut resource = crate::auth::policies::ResourceRef::new(
11128            resource_kind.to_string(),
11129            resource_name.to_string(),
11130        );
11131        if let Some(t) = tenant {
11132            resource = resource.with_tenant(t.to_string());
11133        }
11134        if auth_store.check_policy_authz_with_role(principal, action, &resource, &ctx, role) {
11135            Ok(())
11136        } else {
11137            Err(format!(
11138                "principal=`{}` action=`{}` resource=`{}:{}` denied by IAM policy",
11139                principal, action, resource.kind, resource.name
11140            ))
11141        }
11142    }
11143
11144    fn check_managed_config_write_for_set_config(&self, key: &str) -> RedDBResult<()> {
11145        let Some(auth_store) = self.inner.auth_store.read().clone() else {
11146            return Ok(());
11147        };
11148        let (username, role) = current_auth_identity()
11149            .unwrap_or_else(|| ("anonymous".to_string(), crate::auth::Role::Read));
11150        let tenant = current_tenant();
11151        let principal = crate::auth::UserId::from_parts(tenant.as_deref(), &username);
11152        let ctx = runtime_iam_context(role, tenant.as_deref());
11153        let gate = crate::auth::managed_config::ManagedConfigGate::new(
11154            self.inner.config_registry.as_ref(),
11155        );
11156        match gate.check_write(&auth_store, &principal, &ctx, key) {
11157            crate::auth::managed_config::ManagedConfigDecision::PassThrough { .. }
11158            | crate::auth::managed_config::ManagedConfigDecision::Allow { .. } => Ok(()),
11159            crate::auth::managed_config::ManagedConfigDecision::Deny { reason, .. } => {
11160                Err(RedDBError::Query(format!(
11161                    "permission denied: managed config mutation blocked for `{key}`: {reason}"
11162                )))
11163            }
11164        }
11165    }
11166
11167    fn check_secret_write_privilege(
11168        &self,
11169        auth_store: &Arc<crate::auth::store::AuthStore>,
11170        key: &str,
11171    ) -> RedDBResult<()> {
11172        let Some((username, role)) = current_auth_identity() else {
11173            return Ok(());
11174        };
11175        let tenant = current_tenant();
11176        let principal = crate::auth::UserId::from_parts(tenant.as_deref(), &username);
11177        let mut resource =
11178            crate::auth::policies::ResourceRef::new("secret".to_string(), key.to_string());
11179        if let Some(tenant) = &tenant {
11180            resource = resource.with_tenant(tenant.clone());
11181        }
11182        let ctx = runtime_iam_context(role, tenant.as_deref());
11183        if auth_store.check_policy_authz_with_role(
11184            &principal,
11185            "secret:write",
11186            &resource,
11187            &ctx,
11188            role,
11189        ) {
11190            return Ok(());
11191        }
11192        Err(RedDBError::Query(format!(
11193            "permission denied: principal=`{}` action=`secret:write` resource=`secret:{}` denied by IAM policy",
11194            principal, key
11195        )))
11196    }
11197
11198    /// IAM gate for `SET KV` / `DELETE KV` writes (#1602). Mirrors
11199    /// [`Self::check_secret_write_privilege`]: embedded/anonymous callers
11200    /// (no thread-local identity) pass, and `LegacyRbac` lets admins
11201    /// through by default. Under `PolicyOnly` a principal needs an explicit
11202    /// `kv:write` grant on `kv:<key>`.
11203    fn check_kv_write_privilege(
11204        &self,
11205        auth_store: &Arc<crate::auth::store::AuthStore>,
11206        key: &str,
11207    ) -> RedDBResult<()> {
11208        let Some((username, role)) = current_auth_identity() else {
11209            return Ok(());
11210        };
11211        let tenant = current_tenant();
11212        let principal = crate::auth::UserId::from_parts(tenant.as_deref(), &username);
11213        let mut resource =
11214            crate::auth::policies::ResourceRef::new("kv".to_string(), key.to_string());
11215        if let Some(tenant) = &tenant {
11216            resource = resource.with_tenant(tenant.clone());
11217        }
11218        let ctx = runtime_iam_context(role, tenant.as_deref());
11219        if auth_store.check_policy_authz_with_role(&principal, "kv:write", &resource, &ctx, role) {
11220            return Ok(());
11221        }
11222        Err(RedDBError::Query(format!(
11223            "permission denied: principal=`{}` action=`kv:write` resource=`kv:{}` denied by IAM policy",
11224            principal, key
11225        )))
11226    }
11227
11228    /// IAM privilege check for a granular queue operation (issue #755 /
11229    /// PRD #735).
11230    ///
11231    /// Each queue operation maps to a stable verb in
11232    /// [`crate::auth::action_catalog`] (`queue:enqueue`, `queue:read`,
11233    /// `queue:peek`, `queue:ack`, `queue:nack`, `queue:retry`,
11234    /// `queue:dlq:move`, `queue:purge`, `queue:presence:read`). The
11235    /// resource is `queue:<name>` scoped to the current tenant. In
11236    /// legacy mode (no IAM authorization configured) the check is a
11237    /// no-op — the role gates in `execute_queue_command` still apply
11238    /// and the legacy `select` / `write` grant table continues to
11239    /// govern queue access. In IAM-enabled mode a missing granular
11240    /// grant yields a structured, UI-safe error of the form
11241    /// `principal=… action=queue:… resource=queue:… denied by IAM
11242    /// policy` so Red UI can surface the failing toolbar action.
11243    fn check_queue_op_privilege(
11244        &self,
11245        auth_store: &Arc<crate::auth::store::AuthStore>,
11246        principal: &crate::auth::UserId,
11247        role: crate::auth::Role,
11248        tenant: Option<&str>,
11249        action: &str,
11250        queue: &str,
11251    ) -> Result<(), String> {
11252        if !auth_store.iam_authorization_enabled() {
11253            return Ok(());
11254        }
11255        let mut resource =
11256            crate::auth::policies::ResourceRef::new("queue".to_string(), queue.to_string());
11257        if let Some(t) = tenant {
11258            resource = resource.with_tenant(t.to_string());
11259        }
11260        let ctx = runtime_iam_context(role, tenant);
11261        if auth_store.check_policy_authz_with_role(principal, action, &resource, &ctx, role) {
11262            Ok(())
11263        } else {
11264            Err(format!(
11265                "principal=`{}` action=`{}` resource=`queue:{}` denied by IAM policy",
11266                principal, action, queue
11267            ))
11268        }
11269    }
11270
11271    /// IAM privilege check for a graph operation (issue #757 / PRD
11272    /// #735).
11273    ///
11274    /// Each graph operation maps to a stable verb in
11275    /// [`crate::auth::action_catalog`] — `graph:read` for
11276    /// metadata/property lookups, `graph:traverse` for MATCH / PATH /
11277    /// NEIGHBORHOOD / TRAVERSE / SHORTEST_PATH, and
11278    /// `graph:algorithm:run` for analytics algorithms (centrality,
11279    /// community, components, cycles, clustering, topological sort).
11280    /// The resource is `graph:*` scoped to the current tenant — the
11281    /// runtime today operates on a singleton graph store so the name
11282    /// has no concrete identifier; policies grant the explorer
11283    /// surface by writing `graph:*` as the resource pattern.
11284    ///
11285    /// In legacy mode (no IAM authorization configured) the check is
11286    /// a no-op so the existing role-based defaults continue to
11287    /// govern. In IAM-enabled mode a missing grant produces the
11288    /// UI-safe envelope `principal=… action=graph:… resource=graph:*
11289    /// denied by IAM policy` Red UI keys on.
11290    fn check_graph_op_privilege(
11291        &self,
11292        auth_store: &Arc<crate::auth::store::AuthStore>,
11293        principal: &crate::auth::UserId,
11294        role: crate::auth::Role,
11295        tenant: Option<&str>,
11296        action: &str,
11297    ) -> Result<(), String> {
11298        if !auth_store.iam_authorization_enabled() {
11299            return Ok(());
11300        }
11301        let mut resource =
11302            crate::auth::policies::ResourceRef::new("graph".to_string(), "*".to_string());
11303        if let Some(t) = tenant {
11304            resource = resource.with_tenant(t.to_string());
11305        }
11306        let ctx = runtime_iam_context(role, tenant);
11307        if auth_store.check_policy_authz_with_role(principal, action, &resource, &ctx, role) {
11308            Ok(())
11309        } else {
11310            Err(format!(
11311                "principal=`{}` action=`{}` resource=`graph:*` denied by IAM policy",
11312                principal, action
11313            ))
11314        }
11315    }
11316
11317    /// IAM privilege check for a granular vector operation (issue #756
11318    /// / PRD #735).
11319    ///
11320    /// Each vector operation maps to a stable verb in
11321    /// [`crate::auth::action_catalog`] (`vector:read`, `vector:search`,
11322    /// `vector:artifact:read`, `vector:artifact:rebuild`,
11323    /// `vector:admin`). The resource is `vector:<collection>` scoped to
11324    /// the current tenant. In legacy mode (no IAM authorization
11325    /// configured) the check is a no-op — the role gates and existing
11326    /// `select` / column-projection grants continue to govern access.
11327    /// In IAM-enabled mode a missing granular grant yields a
11328    /// structured, UI-safe error of the form `principal=…
11329    /// action=vector:… resource=vector:… denied by IAM policy` so Red
11330    /// UI can surface the failing toolbar action.
11331    fn check_vector_op_privilege(
11332        &self,
11333        auth_store: &Arc<crate::auth::store::AuthStore>,
11334        principal: &crate::auth::UserId,
11335        role: crate::auth::Role,
11336        tenant: Option<&str>,
11337        action: &str,
11338        collection: &str,
11339    ) -> Result<(), String> {
11340        if !auth_store.iam_authorization_enabled() {
11341            return Ok(());
11342        }
11343        let mut resource =
11344            crate::auth::policies::ResourceRef::new("vector".to_string(), collection.to_string());
11345        if let Some(t) = tenant {
11346            resource = resource.with_tenant(t.to_string());
11347        }
11348        let ctx = runtime_iam_context(role, tenant);
11349        if auth_store.check_policy_authz_with_role(principal, action, &resource, &ctx, role) {
11350            Ok(())
11351        } else {
11352            Err(format!(
11353                "principal=`{}` action=`{}` resource=`vector:{}` denied by IAM policy",
11354                principal, action, collection
11355            ))
11356        }
11357    }
11358
11359    /// IAM privilege check for DROP / TRUNCATE on a named collection.
11360    ///
11361    /// Delegates to [`check_ddl_object_privilege`] with `resource_kind =
11362    /// "collection"`. Kept as a thin wrapper so the existing DROP/TRUNCATE
11363    /// callsites stay readable.
11364    fn check_ddl_collection_privilege(
11365        &self,
11366        auth_store: &Arc<crate::auth::store::AuthStore>,
11367        principal: &crate::auth::UserId,
11368        role: crate::auth::Role,
11369        tenant: Option<&str>,
11370        username: &str,
11371        action: &str,
11372        collection: &str,
11373    ) -> Result<(), String> {
11374        self.check_ddl_object_privilege(
11375            auth_store,
11376            principal,
11377            role,
11378            tenant,
11379            username,
11380            action,
11381            "collection",
11382            collection,
11383            crate::auth::Role::Write,
11384        )
11385    }
11386
11387    /// Generalised IAM privilege check for DDL on a named object.
11388    ///
11389    /// `action` is the stable verb advertised through the action catalog
11390    /// (`create`, `alter`, `drop`, `truncate`, `schema:write`,
11391    /// `schema:admin`). `resource_kind` / `resource_name` form the policy
11392    /// resource (`collection:<name>`, `schema:<name>`). `min_role` is the
11393    /// legacy gate when IAM is not yet enabled.
11394    ///
11395    /// Behaviour:
11396    /// * Role below `min_role` → structured "principal=… role=… cannot
11397    ///   issue DDL" denial, audit recorded.
11398    /// * IAM disabled → audit-record success and allow (legacy path).
11399    /// * IAM enabled → call `check_policy_authz_with_role`. Explicit Deny
11400    ///   and DefaultDeny in PolicyOnly mode both produce a UI-safe
11401    ///   "principal=… action=… resource=<kind>:<name> denied by IAM
11402    ///   policy" string. Explicit Allow and the LegacyRbac fallback
11403    ///   allow the action.
11404    #[allow(clippy::too_many_arguments)]
11405    fn check_ddl_object_privilege(
11406        &self,
11407        auth_store: &Arc<crate::auth::store::AuthStore>,
11408        principal: &crate::auth::UserId,
11409        role: crate::auth::Role,
11410        tenant: Option<&str>,
11411        username: &str,
11412        action: &str,
11413        resource_kind: &str,
11414        resource_name: &str,
11415        min_role: crate::auth::Role,
11416    ) -> Result<(), String> {
11417        if role < min_role {
11418            let msg = format!(
11419                "principal=`{}` role=`{:?}` cannot issue DDL action=`{}` resource=`{}:{}`",
11420                username, role, action, resource_kind, resource_name
11421            );
11422            self.inner.audit_log.record(
11423                action,
11424                username,
11425                resource_name,
11426                "denied",
11427                crate::json::Value::Null,
11428            );
11429            return Err(msg);
11430        }
11431
11432        if !auth_store.iam_authorization_enabled() {
11433            self.inner.audit_log.record(
11434                action,
11435                username,
11436                resource_name,
11437                "ok",
11438                crate::json::Value::Null,
11439            );
11440            return Ok(());
11441        }
11442
11443        let mut resource = crate::auth::policies::ResourceRef::new(
11444            resource_kind.to_string(),
11445            resource_name.to_string(),
11446        );
11447        if let Some(t) = tenant {
11448            resource = resource.with_tenant(t.to_string());
11449        }
11450        let ctx = runtime_iam_context(role, tenant);
11451        if auth_store.check_policy_authz_with_role(principal, action, &resource, &ctx, role) {
11452            self.inner.audit_log.record(
11453                action,
11454                username,
11455                resource_name,
11456                "ok",
11457                crate::json::Value::Null,
11458            );
11459            Ok(())
11460        } else {
11461            self.inner.audit_log.record(
11462                action,
11463                username,
11464                resource_name,
11465                "denied",
11466                crate::json::Value::Null,
11467            );
11468            Err(format!(
11469                "principal=`{}` action=`{}` resource=`{}:{}` denied by IAM policy",
11470                username, action, resource_kind, resource_name
11471            ))
11472        }
11473    }
11474
11475    /// Translate the parsed [`GrantStmt`] into AuthStore mutations.
11476    fn execute_grant_statement(
11477        &self,
11478        query: &str,
11479        stmt: &crate::storage::query::ast::GrantStmt,
11480    ) -> RedDBResult<RuntimeQueryResult> {
11481        use crate::auth::privileges::{Action, GrantPrincipal, Resource};
11482        use crate::auth::UserId;
11483        use crate::storage::query::ast::{GrantObjectKind, GrantPrincipalRef};
11484
11485        let auth_store = self
11486            .inner
11487            .auth_store
11488            .read()
11489            .clone()
11490            .ok_or_else(|| RedDBError::Query("auth store not configured".to_string()))?;
11491
11492        // Granter identity + role.
11493        let (gname, grole) = current_auth_identity().ok_or_else(|| {
11494            RedDBError::Query("GRANT requires an authenticated principal".to_string())
11495        })?;
11496        let granter = UserId::from_parts(current_tenant().as_deref(), &gname);
11497        let granter_role = grole;
11498
11499        // Build the action set.
11500        let mut actions: Vec<Action> = Vec::new();
11501        if stmt.all {
11502            actions.push(Action::All);
11503        } else {
11504            for kw in &stmt.actions {
11505                let a = Action::from_keyword(kw).ok_or_else(|| {
11506                    RedDBError::Query(format!("unknown privilege keyword `{}`", kw))
11507                })?;
11508                actions.push(a);
11509            }
11510        }
11511
11512        // Audit emit (printed; structured emission is Agent #4's lane).
11513        let mut applied = 0usize;
11514        for obj in &stmt.objects {
11515            let resource = match stmt.object_kind {
11516                GrantObjectKind::Table => Resource::Table {
11517                    schema: obj.schema.clone(),
11518                    table: obj.name.clone(),
11519                },
11520                GrantObjectKind::Schema => Resource::Schema(obj.name.clone()),
11521                GrantObjectKind::Database => Resource::Database,
11522                GrantObjectKind::Function => Resource::Function {
11523                    schema: obj.schema.clone(),
11524                    name: obj.name.clone(),
11525                },
11526            };
11527            for principal in &stmt.principals {
11528                let p = match principal {
11529                    GrantPrincipalRef::Public => GrantPrincipal::Public,
11530                    GrantPrincipalRef::Group(g) => GrantPrincipal::Group(g.clone()),
11531                    GrantPrincipalRef::User { tenant, name } => {
11532                        GrantPrincipal::User(UserId::from_parts(tenant.as_deref(), name))
11533                    }
11534                };
11535                // Tenant of the grant follows the granter's tenant
11536                // (cross-tenant guard inside `AuthStore::grant`).
11537                let tenant = granter.tenant.clone();
11538                auth_store
11539                    .grant(
11540                        &granter,
11541                        granter_role,
11542                        p.clone(),
11543                        resource.clone(),
11544                        actions.clone(),
11545                        stmt.with_grant_option,
11546                        tenant.clone(),
11547                    )
11548                    .map_err(|e| RedDBError::Query(e.to_string()))?;
11549
11550                // IAM policy translation: every GRANT also lands as a
11551                // synthetic `_grant_<id>` policy attached to the
11552                // principal so the new evaluator sees it.
11553                if let Some(policy) =
11554                    grant_to_iam_policy(&p, &resource, &actions, tenant.as_deref())
11555                {
11556                    let pid = policy.id.clone();
11557                    auth_store
11558                        .put_policy_internal(policy)
11559                        .map_err(|e| RedDBError::Query(e.to_string()))?;
11560                    let attachment = match &p {
11561                        GrantPrincipal::User(uid) => {
11562                            crate::auth::store::PrincipalRef::User(uid.clone())
11563                        }
11564                        GrantPrincipal::Group(group) => {
11565                            crate::auth::store::PrincipalRef::Group(group.clone())
11566                        }
11567                        GrantPrincipal::Public => crate::auth::store::PrincipalRef::Group(
11568                            crate::auth::store::PUBLIC_IAM_GROUP.to_string(),
11569                        ),
11570                    };
11571                    auth_store
11572                        .attach_policy(attachment, &pid)
11573                        .map_err(|e| RedDBError::Query(e.to_string()))?;
11574                }
11575                applied += 1;
11576                tracing::info!(
11577                    target: "audit",
11578                    principal = %granter,
11579                    action = "grant",
11580                    "GRANT applied"
11581                );
11582            }
11583        }
11584
11585        self.invalidate_result_cache();
11586        Ok(RuntimeQueryResult::ok_message(
11587            query.to_string(),
11588            &format!("GRANT applied to {} target(s)", applied),
11589            "grant",
11590        ))
11591    }
11592
11593    /// Translate the parsed [`RevokeStmt`] into AuthStore mutations.
11594    fn execute_revoke_statement(
11595        &self,
11596        query: &str,
11597        stmt: &crate::storage::query::ast::RevokeStmt,
11598    ) -> RedDBResult<RuntimeQueryResult> {
11599        use crate::auth::privileges::{Action, GrantPrincipal, Resource};
11600        use crate::auth::UserId;
11601        use crate::storage::query::ast::{GrantObjectKind, GrantPrincipalRef};
11602
11603        let auth_store = self
11604            .inner
11605            .auth_store
11606            .read()
11607            .clone()
11608            .ok_or_else(|| RedDBError::Query("auth store not configured".to_string()))?;
11609
11610        let (_gname, grole) = current_auth_identity().ok_or_else(|| {
11611            RedDBError::Query("REVOKE requires an authenticated principal".to_string())
11612        })?;
11613        let granter_role = grole;
11614
11615        let actions: Vec<Action> = if stmt.all {
11616            vec![Action::All]
11617        } else {
11618            stmt.actions
11619                .iter()
11620                .map(|kw| Action::from_keyword(kw).unwrap_or(Action::Select))
11621                .collect()
11622        };
11623
11624        let mut total_removed = 0usize;
11625        for obj in &stmt.objects {
11626            let resource = match stmt.object_kind {
11627                GrantObjectKind::Table => Resource::Table {
11628                    schema: obj.schema.clone(),
11629                    table: obj.name.clone(),
11630                },
11631                GrantObjectKind::Schema => Resource::Schema(obj.name.clone()),
11632                GrantObjectKind::Database => Resource::Database,
11633                GrantObjectKind::Function => Resource::Function {
11634                    schema: obj.schema.clone(),
11635                    name: obj.name.clone(),
11636                },
11637            };
11638            for principal in &stmt.principals {
11639                let p = match principal {
11640                    GrantPrincipalRef::Public => GrantPrincipal::Public,
11641                    GrantPrincipalRef::Group(g) => GrantPrincipal::Group(g.clone()),
11642                    GrantPrincipalRef::User { tenant, name } => {
11643                        GrantPrincipal::User(UserId::from_parts(tenant.as_deref(), name))
11644                    }
11645                };
11646                let removed = auth_store
11647                    .revoke(granter_role, &p, &resource, &actions)
11648                    .map_err(|e| RedDBError::Query(e.to_string()))?;
11649                let _removed_policies =
11650                    auth_store.delete_synthetic_grant_policies(&p, &resource, &actions);
11651                total_removed += removed;
11652            }
11653        }
11654
11655        self.invalidate_result_cache();
11656        Ok(RuntimeQueryResult::ok_message(
11657            query.to_string(),
11658            &format!("REVOKE removed {} grant(s)", total_removed),
11659            "revoke",
11660        ))
11661    }
11662
11663    /// Translate the parsed [`CreateUserStmt`] into an AuthStore user.
11664    fn execute_create_user_statement(
11665        &self,
11666        query: &str,
11667        stmt: &crate::storage::query::ast::CreateUserStmt,
11668    ) -> RedDBResult<RuntimeQueryResult> {
11669        let auth_store = self
11670            .inner
11671            .auth_store
11672            .read()
11673            .clone()
11674            .ok_or_else(|| RedDBError::Query("auth store not configured".to_string()))?;
11675
11676        let (_gname, grole) = current_auth_identity().ok_or_else(|| {
11677            RedDBError::Query("CREATE USER requires an authenticated principal".to_string())
11678        })?;
11679        if grole != crate::auth::Role::Admin {
11680            return Err(RedDBError::Query(
11681                "CREATE USER requires Admin role".to_string(),
11682            ));
11683        }
11684
11685        let role = crate::auth::Role::from_str(&stmt.role)
11686            .ok_or_else(|| RedDBError::Query(format!("invalid role `{}`", stmt.role)))?;
11687        let user = auth_store
11688            .create_user_in_tenant(stmt.tenant.as_deref(), &stmt.username, &stmt.password, role)
11689            .map_err(|e| RedDBError::Query(e.to_string()))?;
11690
11691        self.invalidate_result_cache();
11692        let target = crate::auth::UserId::from_parts(user.tenant_id.as_deref(), &user.username);
11693        tracing::info!(
11694            target: "audit",
11695            principal = %target,
11696            role = %role,
11697            action = "create_user",
11698            "CREATE USER applied"
11699        );
11700
11701        Ok(RuntimeQueryResult::ok_message(
11702            query.to_string(),
11703            &format!("CREATE USER {} applied", target),
11704            "create_user",
11705        ))
11706    }
11707
11708    /// Translate the parsed [`AlterUserStmt`] into AuthStore mutations.
11709    fn execute_alter_user_statement(
11710        &self,
11711        query: &str,
11712        stmt: &crate::storage::query::ast::AlterUserStmt,
11713    ) -> RedDBResult<RuntimeQueryResult> {
11714        use crate::auth::privileges::UserAttributes;
11715        use crate::auth::UserId;
11716        use crate::storage::query::ast::AlterUserAttribute;
11717
11718        let auth_store = self
11719            .inner
11720            .auth_store
11721            .read()
11722            .clone()
11723            .ok_or_else(|| RedDBError::Query("auth store not configured".to_string()))?;
11724
11725        let (gname, grole) = current_auth_identity().ok_or_else(|| {
11726            RedDBError::Query("ALTER USER requires an authenticated principal".to_string())
11727        })?;
11728        if grole != crate::auth::Role::Admin {
11729            return Err(RedDBError::Query(
11730                "ALTER USER requires Admin role".to_string(),
11731            ));
11732        }
11733
11734        let target = UserId::from_parts(stmt.tenant.as_deref(), &stmt.username);
11735        let actor_tenant = current_tenant();
11736        let actor = UserId::from_parts(actor_tenant.as_deref(), &gname);
11737        for attr in &stmt.attributes {
11738            let action = match attr {
11739                AlterUserAttribute::Disable => "user:disable",
11740                AlterUserAttribute::Password(_) => "user:password:change",
11741                _ => "user:update",
11742            };
11743            if auth_store.has_explicit_user_lifecycle_deny(&actor, grole, action, &target) {
11744                return Err(RedDBError::Query(format!(
11745                    "ALTER USER denied by IAM policy: action `{action}` resource `user:{target}`"
11746                )));
11747            }
11748        }
11749
11750        // Apply attributes incrementally — each one reads the current
11751        // record, mutates the relevant field, writes back.
11752        let mut attrs = auth_store.user_attributes(&target);
11753        let mut enable_change: Option<bool> = None;
11754
11755        for a in &stmt.attributes {
11756            match a {
11757                AlterUserAttribute::ValidUntil(ts) => {
11758                    // Parse ISO-ish timestamp → ms since epoch. Fall
11759                    // back to integer-ms parsing for callers that pass
11760                    // `'1234567890123'`.
11761                    let ms = parse_timestamp_to_ms(ts).ok_or_else(|| {
11762                        RedDBError::Query(format!("invalid VALID UNTIL timestamp `{ts}`"))
11763                    })?;
11764                    attrs.valid_until = Some(ms);
11765                }
11766                AlterUserAttribute::ConnectionLimit(n) => {
11767                    if *n < 0 {
11768                        return Err(RedDBError::Query(
11769                            "CONNECTION LIMIT must be non-negative".to_string(),
11770                        ));
11771                    }
11772                    attrs.connection_limit = Some(*n as u32);
11773                }
11774                AlterUserAttribute::SetSearchPath(p) => {
11775                    attrs.search_path = Some(p.clone());
11776                }
11777                AlterUserAttribute::AddGroup(g) => {
11778                    if !attrs.groups.iter().any(|existing| existing == g) {
11779                        attrs.groups.push(g.clone());
11780                        attrs.groups.sort();
11781                    }
11782                }
11783                AlterUserAttribute::DropGroup(g) => {
11784                    attrs.groups.retain(|existing| existing != g);
11785                }
11786                AlterUserAttribute::Enable => enable_change = Some(true),
11787                AlterUserAttribute::Disable => enable_change = Some(false),
11788                AlterUserAttribute::Password(_) => {
11789                    // Out of scope — accept the AST but no-op so the
11790                    // parser stays compatible with future password
11791                    // rotation work.
11792                }
11793            }
11794        }
11795
11796        auth_store
11797            .set_user_attributes(&target, attrs)
11798            .map_err(|e| RedDBError::Query(e.to_string()))?;
11799        if let Some(en) = enable_change {
11800            auth_store
11801                .set_user_enabled(&target, en)
11802                .map_err(|e| RedDBError::Query(e.to_string()))?;
11803        }
11804        self.invalidate_result_cache();
11805        tracing::info!(
11806            target: "audit",
11807            principal = %target,
11808            action = "alter_user",
11809            "ALTER USER applied"
11810        );
11811
11812        Ok(RuntimeQueryResult::ok_message(
11813            query.to_string(),
11814            &format!("ALTER USER {} applied", target),
11815            "alter_user",
11816        ))
11817    }
11818
11819    // -----------------------------------------------------------------
11820    // IAM policy executors
11821    // -----------------------------------------------------------------
11822
11823    fn execute_create_iam_policy(
11824        &self,
11825        query: &str,
11826        id: &str,
11827        json: &str,
11828    ) -> RedDBResult<RuntimeQueryResult> {
11829        use crate::auth::policies::Policy;
11830
11831        let auth_store = self
11832            .inner
11833            .auth_store
11834            .read()
11835            .clone()
11836            .ok_or_else(|| RedDBError::Query("auth store not configured".to_string()))?;
11837
11838        // Parse + validate. The kernel rejects oversize / bad shape /
11839        // bad action keywords. If the supplied id differs from the JSON
11840        // id, override it with the SQL-provided id (the JSON id is
11841        // optional context — the SQL DDL form is authoritative).
11842        let mut policy = Policy::from_json_str(json)
11843            .map_err(|e| RedDBError::Query(format!("policy parse: {e}")))?;
11844        if policy.id != id {
11845            policy.id = id.to_string();
11846        }
11847        let pid = policy.id.clone();
11848        let tenant = current_tenant();
11849        let (actor_name, actor_role) = current_auth_identity()
11850            .unwrap_or_else(|| ("anonymous".to_string(), crate::auth::Role::Read));
11851        let actor = crate::auth::UserId::from_parts(tenant.as_deref(), &actor_name);
11852        let eval_ctx = runtime_iam_context(actor_role, tenant.as_deref());
11853        let event_ctx = self.policy_mutation_control_ctx(&actor, tenant.as_deref());
11854        let ledger = self.inner.control_event_ledger.read();
11855        let control = crate::auth::store::PolicyMutationControl {
11856            ctx: &event_ctx,
11857            ledger: ledger.as_ref(),
11858            config: self.inner.control_event_config,
11859            registry: Some(self.inner.config_registry.as_ref()),
11860            actor: &actor,
11861            eval_ctx: &eval_ctx,
11862        };
11863        auth_store
11864            .put_policy_with_control_events(policy, &control)
11865            .map_err(|e| RedDBError::Query(e.to_string()))?;
11866
11867        let principal = actor_name;
11868        tracing::info!(
11869            target: "audit",
11870            principal = %principal,
11871            action = "iam:policy.put",
11872            matched_policy_id = %pid,
11873            "CREATE POLICY applied"
11874        );
11875        self.inner.audit_log.record(
11876            "iam/policy.put",
11877            &principal,
11878            &pid,
11879            "ok",
11880            crate::json::Value::Null,
11881        );
11882
11883        self.invalidate_result_cache();
11884        Ok(RuntimeQueryResult::ok_message(
11885            query.to_string(),
11886            &format!("policy `{pid}` stored"),
11887            "create_iam_policy",
11888        ))
11889    }
11890
11891    fn execute_drop_iam_policy(&self, query: &str, id: &str) -> RedDBResult<RuntimeQueryResult> {
11892        let auth_store = self
11893            .inner
11894            .auth_store
11895            .read()
11896            .clone()
11897            .ok_or_else(|| RedDBError::Query("auth store not configured".to_string()))?;
11898        let tenant = current_tenant();
11899        let (actor_name, actor_role) = current_auth_identity()
11900            .unwrap_or_else(|| ("anonymous".to_string(), crate::auth::Role::Read));
11901        let actor = crate::auth::UserId::from_parts(tenant.as_deref(), &actor_name);
11902        let eval_ctx = runtime_iam_context(actor_role, tenant.as_deref());
11903        let event_ctx = self.policy_mutation_control_ctx(&actor, tenant.as_deref());
11904        let ledger = self.inner.control_event_ledger.read();
11905        let control = crate::auth::store::PolicyMutationControl {
11906            ctx: &event_ctx,
11907            ledger: ledger.as_ref(),
11908            config: self.inner.control_event_config,
11909            registry: Some(self.inner.config_registry.as_ref()),
11910            actor: &actor,
11911            eval_ctx: &eval_ctx,
11912        };
11913        auth_store
11914            .delete_policy_with_control_events(id, &control)
11915            .map_err(|e| RedDBError::Query(e.to_string()))?;
11916
11917        let principal = actor_name;
11918        tracing::info!(
11919            target: "audit",
11920            principal = %principal,
11921            action = "iam:policy.drop",
11922            matched_policy_id = %id,
11923            "DROP POLICY applied"
11924        );
11925        self.inner.audit_log.record(
11926            "iam/policy.drop",
11927            &principal,
11928            id,
11929            "ok",
11930            crate::json::Value::Null,
11931        );
11932
11933        self.invalidate_result_cache();
11934        Ok(RuntimeQueryResult::ok_message(
11935            query.to_string(),
11936            &format!("policy `{id}` dropped"),
11937            "drop_iam_policy",
11938        ))
11939    }
11940
11941    fn execute_attach_policy(
11942        &self,
11943        query: &str,
11944        policy_id: &str,
11945        principal: &crate::storage::query::ast::PolicyPrincipalRef,
11946    ) -> RedDBResult<RuntimeQueryResult> {
11947        use crate::auth::store::PrincipalRef;
11948        use crate::auth::UserId;
11949        use crate::storage::query::ast::PolicyPrincipalRef;
11950
11951        let auth_store = self
11952            .inner
11953            .auth_store
11954            .read()
11955            .clone()
11956            .ok_or_else(|| RedDBError::Query("auth store not configured".to_string()))?;
11957        let p = match principal {
11958            PolicyPrincipalRef::User(u) => {
11959                PrincipalRef::User(UserId::from_parts(u.tenant.as_deref(), &u.username))
11960            }
11961            PolicyPrincipalRef::Group(g) => PrincipalRef::Group(g.clone()),
11962        };
11963        let pretty_target = principal_label(principal);
11964        let tenant = current_tenant();
11965        let (actor_name, actor_role) = current_auth_identity()
11966            .unwrap_or_else(|| ("anonymous".to_string(), crate::auth::Role::Read));
11967        let actor = crate::auth::UserId::from_parts(tenant.as_deref(), &actor_name);
11968        let eval_ctx = runtime_iam_context(actor_role, tenant.as_deref());
11969        let event_ctx = self.policy_mutation_control_ctx(&actor, tenant.as_deref());
11970        let ledger = self.inner.control_event_ledger.read();
11971        let control = crate::auth::store::PolicyMutationControl {
11972            ctx: &event_ctx,
11973            ledger: ledger.as_ref(),
11974            config: self.inner.control_event_config,
11975            registry: Some(self.inner.config_registry.as_ref()),
11976            actor: &actor,
11977            eval_ctx: &eval_ctx,
11978        };
11979        auth_store
11980            .attach_policy_with_control_events(p, policy_id, &control)
11981            .map_err(|e| RedDBError::Query(e.to_string()))?;
11982
11983        let principal_str = actor_name;
11984        tracing::info!(
11985            target: "audit",
11986            principal = %principal_str,
11987            action = "iam:policy.attach",
11988            matched_policy_id = %policy_id,
11989            target = %pretty_target,
11990            "ATTACH POLICY applied"
11991        );
11992        self.inner.audit_log.record(
11993            "iam/policy.attach",
11994            &principal_str,
11995            &pretty_target,
11996            "ok",
11997            crate::json::Value::Null,
11998        );
11999
12000        self.invalidate_result_cache();
12001        Ok(RuntimeQueryResult::ok_message(
12002            query.to_string(),
12003            &format!("policy `{policy_id}` attached to {pretty_target}"),
12004            "attach_policy",
12005        ))
12006    }
12007
12008    fn execute_detach_policy(
12009        &self,
12010        query: &str,
12011        policy_id: &str,
12012        principal: &crate::storage::query::ast::PolicyPrincipalRef,
12013    ) -> RedDBResult<RuntimeQueryResult> {
12014        use crate::auth::store::PrincipalRef;
12015        use crate::auth::UserId;
12016        use crate::storage::query::ast::PolicyPrincipalRef;
12017
12018        let auth_store = self
12019            .inner
12020            .auth_store
12021            .read()
12022            .clone()
12023            .ok_or_else(|| RedDBError::Query("auth store not configured".to_string()))?;
12024        let p = match principal {
12025            PolicyPrincipalRef::User(u) => {
12026                PrincipalRef::User(UserId::from_parts(u.tenant.as_deref(), &u.username))
12027            }
12028            PolicyPrincipalRef::Group(g) => PrincipalRef::Group(g.clone()),
12029        };
12030        let pretty_target = principal_label(principal);
12031        let tenant = current_tenant();
12032        let (actor_name, actor_role) = current_auth_identity()
12033            .unwrap_or_else(|| ("anonymous".to_string(), crate::auth::Role::Read));
12034        let actor = crate::auth::UserId::from_parts(tenant.as_deref(), &actor_name);
12035        let eval_ctx = runtime_iam_context(actor_role, tenant.as_deref());
12036        let event_ctx = self.policy_mutation_control_ctx(&actor, tenant.as_deref());
12037        let ledger = self.inner.control_event_ledger.read();
12038        let control = crate::auth::store::PolicyMutationControl {
12039            ctx: &event_ctx,
12040            ledger: ledger.as_ref(),
12041            config: self.inner.control_event_config,
12042            registry: Some(self.inner.config_registry.as_ref()),
12043            actor: &actor,
12044            eval_ctx: &eval_ctx,
12045        };
12046        auth_store
12047            .detach_policy_with_control_events(p, policy_id, &control)
12048            .map_err(|e| RedDBError::Query(e.to_string()))?;
12049
12050        let principal_str = actor_name;
12051        tracing::info!(
12052            target: "audit",
12053            principal = %principal_str,
12054            action = "iam:policy.detach",
12055            matched_policy_id = %policy_id,
12056            target = %pretty_target,
12057            "DETACH POLICY applied"
12058        );
12059        self.inner.audit_log.record(
12060            "iam/policy.detach",
12061            &principal_str,
12062            &pretty_target,
12063            "ok",
12064            crate::json::Value::Null,
12065        );
12066
12067        self.invalidate_result_cache();
12068        Ok(RuntimeQueryResult::ok_message(
12069            query.to_string(),
12070            &format!("policy `{policy_id}` detached from {pretty_target}"),
12071            "detach_policy",
12072        ))
12073    }
12074
12075    fn execute_show_policies(
12076        &self,
12077        query: &str,
12078        filter: Option<&crate::storage::query::ast::PolicyPrincipalRef>,
12079    ) -> RedDBResult<RuntimeQueryResult> {
12080        use crate::auth::UserId;
12081        use crate::storage::query::ast::PolicyPrincipalRef;
12082        use crate::storage::query::unified::UnifiedRecord;
12083        use crate::storage::schema::Value as SchemaValue;
12084        use std::sync::Arc;
12085
12086        let auth_store = self
12087            .inner
12088            .auth_store
12089            .read()
12090            .clone()
12091            .ok_or_else(|| RedDBError::Query("auth store not configured".to_string()))?;
12092
12093        let pols = match filter {
12094            None => auth_store.list_policies(),
12095            Some(PolicyPrincipalRef::User(u)) => {
12096                let id = UserId::from_parts(u.tenant.as_deref(), &u.username);
12097                auth_store.effective_policies(&id)
12098            }
12099            Some(PolicyPrincipalRef::Group(g)) => auth_store.group_policies(g),
12100        };
12101
12102        let mut records = Vec::with_capacity(pols.len() + 1);
12103
12104        // Header row (#712 / S5A): synthetic record at index 0 that
12105        // reports the active PolicyEnforcementMode and the hard-cutover
12106        // version, so an operator running SHOW POLICIES can see the
12107        // current posture without a separate command.
12108        let mode = auth_store.enforcement_mode();
12109        let mut header = UnifiedRecord::default();
12110        header.set_arc(
12111            Arc::from("id"),
12112            SchemaValue::text("<enforcement_mode>".to_string()),
12113        );
12114        header.set_arc(Arc::from("statements"), SchemaValue::Integer(0));
12115        header.set_arc(Arc::from("tenant"), SchemaValue::Null);
12116        let header_json = format!(
12117            r#"{{"enforcement_mode":"{}","policy_only_hard_version":"{}"}}"#,
12118            mode.as_str(),
12119            crate::auth::enforcement_mode::POLICY_ONLY_HARD_VERSION
12120        );
12121        header.set_arc(Arc::from("json"), SchemaValue::text(header_json));
12122        records.push(header);
12123
12124        for p in pols.iter() {
12125            let mut rec = UnifiedRecord::default();
12126            rec.set_arc(Arc::from("id"), SchemaValue::text(p.id.clone()));
12127            rec.set_arc(
12128                Arc::from("statements"),
12129                SchemaValue::Integer(p.statements.len() as i64),
12130            );
12131            rec.set_arc(
12132                Arc::from("tenant"),
12133                p.tenant
12134                    .as_deref()
12135                    .map(|t| SchemaValue::text(t.to_string()))
12136                    .unwrap_or(SchemaValue::Null),
12137            );
12138            rec.set_arc(Arc::from("json"), SchemaValue::text(p.to_json_string()));
12139            records.push(rec);
12140        }
12141        let mut result = crate::storage::query::unified::UnifiedResult::empty();
12142        result.records = records;
12143        Ok(RuntimeQueryResult {
12144            query: query.to_string(),
12145            mode: crate::storage::query::modes::QueryMode::Sql,
12146            statement: "show_policies",
12147            engine: "iam-policies",
12148            result,
12149            affected_rows: 0,
12150            statement_type: "select",
12151            bookmark: None,
12152        })
12153    }
12154
12155    fn execute_show_effective_permissions(
12156        &self,
12157        query: &str,
12158        user: &crate::storage::query::ast::PolicyUserRef,
12159        resource: Option<&crate::storage::query::ast::PolicyResourceRef>,
12160    ) -> RedDBResult<RuntimeQueryResult> {
12161        use crate::auth::UserId;
12162        use crate::storage::query::unified::UnifiedRecord;
12163        use crate::storage::schema::Value as SchemaValue;
12164        use std::sync::Arc;
12165
12166        let auth_store = self
12167            .inner
12168            .auth_store
12169            .read()
12170            .clone()
12171            .ok_or_else(|| RedDBError::Query("auth store not configured".to_string()))?;
12172        let id = UserId::from_parts(user.tenant.as_deref(), &user.username);
12173        let pols = auth_store.effective_policies(&id);
12174
12175        // Show one row per (policy, statement) tuple, plus any
12176        // resource-level filter passed by the caller.
12177        let mut records = Vec::new();
12178        for p in pols.iter() {
12179            for (idx, st) in p.statements.iter().enumerate() {
12180                if let Some(_r) = resource {
12181                    // Naive filter: render statement targets to strings
12182                    // and skip if no match. Conservative default = include
12183                    // (the simulator handles fine-grained matching).
12184                }
12185                let mut rec = UnifiedRecord::default();
12186                rec.set_arc(Arc::from("policy_id"), SchemaValue::text(p.id.clone()));
12187                rec.set_arc(
12188                    Arc::from("statement_index"),
12189                    SchemaValue::Integer(idx as i64),
12190                );
12191                rec.set_arc(
12192                    Arc::from("sid"),
12193                    st.sid
12194                        .as_deref()
12195                        .map(|s| SchemaValue::text(s.to_string()))
12196                        .unwrap_or(SchemaValue::Null),
12197                );
12198                rec.set_arc(
12199                    Arc::from("effect"),
12200                    SchemaValue::text(match st.effect {
12201                        crate::auth::policies::Effect::Allow => "allow",
12202                        crate::auth::policies::Effect::Deny => "deny",
12203                    }),
12204                );
12205                rec.set_arc(
12206                    Arc::from("actions"),
12207                    SchemaValue::Integer(st.actions.len() as i64),
12208                );
12209                rec.set_arc(
12210                    Arc::from("resources"),
12211                    SchemaValue::Integer(st.resources.len() as i64),
12212                );
12213                records.push(rec);
12214            }
12215        }
12216        let mut result = crate::storage::query::unified::UnifiedResult::empty();
12217        result.records = records;
12218        Ok(RuntimeQueryResult {
12219            query: query.to_string(),
12220            mode: crate::storage::query::modes::QueryMode::Sql,
12221            statement: "show_effective_permissions",
12222            engine: "iam-policies",
12223            result,
12224            affected_rows: 0,
12225            statement_type: "select",
12226            bookmark: None,
12227        })
12228    }
12229
12230    fn execute_lint_policy(
12231        &self,
12232        query: &str,
12233        source: &crate::storage::query::ast::LintPolicySource,
12234    ) -> RedDBResult<RuntimeQueryResult> {
12235        use crate::auth::policy_linter::lint;
12236        use crate::storage::query::ast::LintPolicySource;
12237        use crate::storage::query::unified::UnifiedRecord;
12238        use crate::storage::schema::Value as SchemaValue;
12239        use std::sync::Arc;
12240
12241        // Resolve the policy text. `JSON` source lints the literal
12242        // verbatim; `Id` source fetches the stored document so
12243        // operators can lint a policy by name without rebuilding the
12244        // JSON from `SHOW POLICY`.
12245        let policy_text = match source {
12246            LintPolicySource::Json(text) => text.clone(),
12247            LintPolicySource::Id(id) => {
12248                let auth_store =
12249                    self.inner.auth_store.read().clone().ok_or_else(|| {
12250                        RedDBError::Query("auth store not configured".to_string())
12251                    })?;
12252                let policy = auth_store
12253                    .get_policy(id)
12254                    .ok_or_else(|| RedDBError::Query(format!("policy `{id}` not found")))?;
12255                policy.to_json_string()
12256            }
12257        };
12258        let diagnostics = lint(&policy_text);
12259
12260        let principal_str = current_auth_identity()
12261            .map(|(u, _)| u)
12262            .unwrap_or_else(|| "anonymous".into());
12263        tracing::info!(
12264            target: "audit",
12265            principal = %principal_str,
12266            action = "iam:policy.lint",
12267            diagnostic_count = diagnostics.len(),
12268            "LINT POLICY issued"
12269        );
12270        self.inner.audit_log.record(
12271            "iam/policy.lint",
12272            &principal_str,
12273            match source {
12274                LintPolicySource::Id(id) => id.as_str(),
12275                LintPolicySource::Json(_) => "<json>",
12276            },
12277            "ok",
12278            crate::json::Value::Null,
12279        );
12280
12281        // One row per diagnostic. Column order matches the HTTP
12282        // surface's JSON keys so the two contracts line up.
12283        const COLUMNS: [&str; 5] = ["severity", "code", "message", "suggested_fix", "location"];
12284        let schema = Arc::new(
12285            COLUMNS
12286                .iter()
12287                .map(|name| Arc::<str>::from(*name))
12288                .collect::<Vec<_>>(),
12289        );
12290        let records: Vec<UnifiedRecord> = diagnostics
12291            .iter()
12292            .map(|d| {
12293                UnifiedRecord::with_schema(
12294                    Arc::clone(&schema),
12295                    vec![
12296                        SchemaValue::text(d.severity.as_str()),
12297                        SchemaValue::text(d.code.as_str()),
12298                        SchemaValue::text(d.message.clone()),
12299                        d.suggested_fix
12300                            .as_deref()
12301                            .map(SchemaValue::text)
12302                            .unwrap_or(SchemaValue::Null),
12303                        d.location
12304                            .as_deref()
12305                            .map(SchemaValue::text)
12306                            .unwrap_or(SchemaValue::Null),
12307                    ],
12308                )
12309            })
12310            .collect();
12311        let mut result = crate::storage::query::unified::UnifiedResult::with_columns(
12312            COLUMNS.iter().map(|c| c.to_string()).collect(),
12313        );
12314        result.records = records;
12315        Ok(RuntimeQueryResult {
12316            query: query.to_string(),
12317            mode: crate::storage::query::modes::QueryMode::Sql,
12318            statement: "lint_policy",
12319            engine: "iam-policies",
12320            result,
12321            affected_rows: 0,
12322            statement_type: "select",
12323            bookmark: None,
12324        })
12325    }
12326
12327    /// `MIGRATE POLICY MODE TO '<target>' [DRY RUN]` — flip the install
12328    /// from `legacy_rbac` to `policy_only` after the pre-flight delta
12329    /// simulator confirms no non-admin principal would lose access.
12330    /// Issue #714.
12331    fn execute_migrate_policy_mode(
12332        &self,
12333        query: &str,
12334        target: &str,
12335        dry_run: bool,
12336    ) -> RedDBResult<RuntimeQueryResult> {
12337        use crate::auth::enforcement_mode::PolicyEnforcementMode;
12338        use crate::auth::migrate_policy_mode::{
12339            principal_label, simulate_migration_delta, MigratePolicyDelta,
12340        };
12341        use crate::auth::policies::ResourceRef;
12342        use crate::storage::query::unified::UnifiedRecord;
12343        use crate::storage::schema::Value as SchemaValue;
12344        use std::sync::Arc;
12345
12346        // Only `policy_only` is a meaningful destination for this
12347        // command — flipping back to `legacy_rbac` is supported via
12348        // direct config writes (it doesn't need a pre-flight). We
12349        // reject everything else with the same allowlist `parse` uses.
12350        let parsed = PolicyEnforcementMode::parse(target).ok_or_else(|| {
12351            RedDBError::Query(format!(
12352                "MIGRATE POLICY MODE: invalid target `{target}` (expected `policy_only`)"
12353            ))
12354        })?;
12355        if parsed != PolicyEnforcementMode::PolicyOnly {
12356            return Err(RedDBError::Query(format!(
12357                "MIGRATE POLICY MODE: target `{target}` is not supported — only `policy_only` may be migrated to via this command"
12358            )));
12359        }
12360
12361        let auth_store = self
12362            .inner
12363            .auth_store
12364            .read()
12365            .clone()
12366            .ok_or_else(|| RedDBError::Query("auth store not configured".to_string()))?;
12367
12368        // Resource enumeration: every existing collection probed as
12369        // `table:<name>`. This is the realistic resource surface for
12370        // the legacy_rbac fallback (the role floors gate per-table
12371        // actions). Wildcard / column-scoped resources are still
12372        // covered by the policy evaluator because evaluate() resolves
12373        // resource patterns relative to the concrete resources we
12374        // probe here.
12375        let snapshot = self.inner.db.catalog_model_snapshot();
12376        let resources: Vec<ResourceRef> = snapshot
12377            .collections
12378            .iter()
12379            .map(|c| ResourceRef::new("table", c.name.clone()))
12380            .collect();
12381
12382        let now_ms = crate::utils::now_unix_millis() as u128;
12383        let deltas: Vec<MigratePolicyDelta> =
12384            simulate_migration_delta(auth_store.as_ref(), &resources, now_ms);
12385
12386        let principal_str = current_auth_identity()
12387            .map(|(u, _)| u)
12388            .unwrap_or_else(|| "anonymous".into());
12389
12390        // Audit every issuance. The outcome line differentiates
12391        // dry-run, refused, and applied — operators can grep for these
12392        // strings in the audit log.
12393        let outcome_str = if dry_run {
12394            "dry_run"
12395        } else if deltas.is_empty() {
12396            "applied"
12397        } else {
12398            "refused"
12399        };
12400        tracing::info!(
12401            target: "audit",
12402            principal = %principal_str,
12403            action = "iam:policy.migrate_mode",
12404            target = %target,
12405            dry_run,
12406            delta_count = deltas.len(),
12407            outcome = outcome_str,
12408            "MIGRATE POLICY MODE issued"
12409        );
12410        self.inner.audit_log.record(
12411            "iam/policy.migrate_mode",
12412            &principal_str,
12413            target,
12414            outcome_str,
12415            crate::json::Value::Null,
12416        );
12417
12418        // Refuse the non-dry-run path when any principal would lose
12419        // access. The error string carries a compact summary plus the
12420        // delta count so operators can re-run with DRY RUN to inspect.
12421        if !dry_run && !deltas.is_empty() {
12422            let summary = deltas
12423                .iter()
12424                .take(5)
12425                .map(|d| {
12426                    format!(
12427                        "{}:{}/{}:{}",
12428                        principal_label(&d.principal),
12429                        d.action,
12430                        d.resource_kind,
12431                        d.resource_name
12432                    )
12433                })
12434                .collect::<Vec<_>>()
12435                .join(", ");
12436            let more = if deltas.len() > 5 {
12437                format!(" (and {} more)", deltas.len() - 5)
12438            } else {
12439                String::new()
12440            };
12441            return Err(RedDBError::Query(format!(
12442                "MIGRATE POLICY MODE refused: {n} principal/action/resource pair(s) would lose access under `policy_only`. Run `MIGRATE POLICY MODE TO '{target}' DRY RUN` to inspect. Sample: {summary}{more}",
12443                n = deltas.len(),
12444            )));
12445        }
12446
12447        // Mutate the live enforcement mode only on the non-dry-run
12448        // path with an empty delta. `set_enforcement_mode` also
12449        // persists to vault_kv so the new mode survives restart.
12450        if !dry_run {
12451            auth_store.set_enforcement_mode(parsed);
12452        }
12453
12454        const COLUMNS: [&str; 5] = [
12455            "principal",
12456            "role",
12457            "action",
12458            "resource_kind",
12459            "resource_name",
12460        ];
12461        let schema = Arc::new(
12462            COLUMNS
12463                .iter()
12464                .map(|name| Arc::<str>::from(*name))
12465                .collect::<Vec<_>>(),
12466        );
12467        let records: Vec<UnifiedRecord> = deltas
12468            .iter()
12469            .map(|d| {
12470                UnifiedRecord::with_schema(
12471                    Arc::clone(&schema),
12472                    vec![
12473                        SchemaValue::text(principal_label(&d.principal)),
12474                        SchemaValue::text(d.role.as_str()),
12475                        SchemaValue::text(d.action.clone()),
12476                        SchemaValue::text(d.resource_kind.clone()),
12477                        SchemaValue::text(d.resource_name.clone()),
12478                    ],
12479                )
12480            })
12481            .collect();
12482        let mut result = crate::storage::query::unified::UnifiedResult::with_columns(
12483            COLUMNS.iter().map(|c| c.to_string()).collect(),
12484        );
12485        result.records = records;
12486        Ok(RuntimeQueryResult {
12487            query: query.to_string(),
12488            mode: crate::storage::query::modes::QueryMode::Sql,
12489            statement: "migrate_policy_mode",
12490            engine: "iam-policies",
12491            result,
12492            affected_rows: 0,
12493            statement_type: "select",
12494            bookmark: None,
12495        })
12496    }
12497
12498    fn execute_simulate_policy(
12499        &self,
12500        query: &str,
12501        user: &crate::storage::query::ast::PolicyUserRef,
12502        action: &str,
12503        resource: &crate::storage::query::ast::PolicyResourceRef,
12504    ) -> RedDBResult<RuntimeQueryResult> {
12505        use crate::auth::policies::ResourceRef;
12506        use crate::auth::store::SimCtx;
12507        use crate::auth::UserId;
12508        use crate::storage::query::unified::UnifiedRecord;
12509        use crate::storage::schema::Value as SchemaValue;
12510        use std::sync::Arc;
12511
12512        let auth_store = self
12513            .inner
12514            .auth_store
12515            .read()
12516            .clone()
12517            .ok_or_else(|| RedDBError::Query("auth store not configured".to_string()))?;
12518        let id = UserId::from_parts(user.tenant.as_deref(), &user.username);
12519        let r = ResourceRef::new(resource.kind.clone(), resource.name.clone());
12520        let outcome = auth_store.simulate(&id, action, &r, SimCtx::default());
12521
12522        let principal_str = current_auth_identity()
12523            .map(|(u, _)| u)
12524            .unwrap_or_else(|| "anonymous".into());
12525        let (decision_str, matched_pid, matched_sid) = decision_to_strings(&outcome.decision);
12526        tracing::info!(
12527            target: "audit",
12528            principal = %principal_str,
12529            action = "iam:policy.simulate",
12530            decision = %decision_str,
12531            matched_policy_id = ?matched_pid,
12532            matched_sid = ?matched_sid,
12533            "SIMULATE issued"
12534        );
12535        self.inner.audit_log.record(
12536            "iam/policy.simulate",
12537            &principal_str,
12538            &id.to_string(),
12539            "ok",
12540            crate::json::Value::Null,
12541        );
12542
12543        let mut rec = UnifiedRecord::default();
12544        rec.set_arc(Arc::from("decision"), SchemaValue::text(decision_str));
12545        rec.set_arc(
12546            Arc::from("matched_policy_id"),
12547            matched_pid
12548                .map(SchemaValue::text)
12549                .unwrap_or(SchemaValue::Null),
12550        );
12551        rec.set_arc(
12552            Arc::from("matched_sid"),
12553            matched_sid
12554                .map(SchemaValue::text)
12555                .unwrap_or(SchemaValue::Null),
12556        );
12557        rec.set_arc(Arc::from("reason"), SchemaValue::text(outcome.reason));
12558        rec.set_arc(
12559            Arc::from("trail_len"),
12560            SchemaValue::Integer(outcome.trail.len() as i64),
12561        );
12562        let mut result = crate::storage::query::unified::UnifiedResult::empty();
12563        result.records = vec![rec];
12564        Ok(RuntimeQueryResult {
12565            query: query.to_string(),
12566            mode: crate::storage::query::modes::QueryMode::Sql,
12567            statement: "simulate_policy",
12568            engine: "iam-policies",
12569            result,
12570            affected_rows: 0,
12571            statement_type: "select",
12572            bookmark: None,
12573        })
12574    }
12575}
12576
12577/// Translate a parsed GRANT into a synthetic IAM policy whose id
12578/// starts with `_grant_<unique>`. PUBLIC is represented as an
12579/// implicit IAM group; legacy GROUP grants are still rejected by the
12580/// grant store and are not translated here.
12581fn grant_to_iam_policy(
12582    principal: &crate::auth::privileges::GrantPrincipal,
12583    resource: &crate::auth::privileges::Resource,
12584    actions: &[crate::auth::privileges::Action],
12585    tenant: Option<&str>,
12586) -> Option<crate::auth::policies::Policy> {
12587    use crate::auth::policies::{
12588        compile_action, ActionPattern, Effect, Policy, ResourcePattern, Statement,
12589    };
12590    use crate::auth::privileges::{Action, GrantPrincipal, Resource};
12591
12592    if matches!(principal, GrantPrincipal::Group(_)) {
12593        return None;
12594    }
12595
12596    let now = crate::auth::now_ms();
12597    let id = format!("_grant_{:x}_{:x}", now, std::process::id());
12598
12599    let resource_str = match resource {
12600        Resource::Database => "table:*".to_string(),
12601        Resource::Schema(s) => format!("table:{s}.*"),
12602        Resource::Table { schema, table } => match schema {
12603            Some(s) => format!("table:{s}.{table}"),
12604            None => format!("table:{table}"),
12605        },
12606        Resource::Function { schema, name } => match schema {
12607            Some(s) => format!("function:{s}.{name}"),
12608            None => format!("function:{name}"),
12609        },
12610    };
12611
12612    // Compile actions — fall back to `*` only when the grant included
12613    // `Action::All`. Map every other action keyword to its lowercase
12614    // form so it lines up with the kernel's allowlist.
12615    let action_patterns: Vec<ActionPattern> = if actions.contains(&Action::All) {
12616        vec![ActionPattern::Wildcard]
12617    } else {
12618        actions
12619            .iter()
12620            .map(|a| compile_action(&a.as_str().to_ascii_lowercase()))
12621            .collect()
12622    };
12623    if action_patterns.is_empty() {
12624        return None;
12625    }
12626
12627    // Inline resource compilation matching the kernel's `compile_resource`:
12628    //   * `*` → wildcard
12629    //   * contains `*` → glob
12630    //   * `kind:name` → exact
12631    let resource_patterns = if resource_str == "*" {
12632        vec![ResourcePattern::Wildcard]
12633    } else if resource_str.contains('*') {
12634        vec![ResourcePattern::Glob(resource_str.clone())]
12635    } else if let Some((kind, name)) = resource_str.split_once(':') {
12636        vec![ResourcePattern::Exact {
12637            kind: kind.to_string(),
12638            name: name.to_string(),
12639        }]
12640    } else {
12641        vec![ResourcePattern::Wildcard]
12642    };
12643
12644    let policy = Policy {
12645        id,
12646        version: 1,
12647        tenant: tenant.map(|t| t.to_string()),
12648        created_at: now,
12649        updated_at: now,
12650        statements: vec![Statement {
12651            sid: None,
12652            effect: Effect::Allow,
12653            actions: action_patterns,
12654            resources: resource_patterns,
12655            condition: None,
12656        }],
12657    };
12658    if policy.validate().is_err() {
12659        return None;
12660    }
12661    Some(policy)
12662}
12663
12664/// Coerce a `key => <number>` table-function named argument into a positive
12665/// iteration count for the centrality TVFs (issue #797). The parser lexes all
12666/// named values as `f64`, so an integral, finite, strictly-positive value is
12667/// required here; anything else (fractional, zero, negative, NaN/inf) is a
12668/// clear query error. `func` names the function for the message.
12669fn parse_positive_iterations(func: &str, value: &f64) -> RedDBResult<usize> {
12670    if !value.is_finite() || *value < 1.0 || value.fract() != 0.0 {
12671        return Err(RedDBError::Query(format!(
12672            "table function '{func}' max_iterations must be a positive integer, got {value}"
12673        )));
12674    }
12675    Ok(*value as usize)
12676}
12677
12678fn legacy_action_to_iam(action: crate::auth::privileges::Action) -> &'static str {
12679    use crate::auth::privileges::Action;
12680    match action {
12681        Action::Select => "select",
12682        Action::Insert => "insert",
12683        Action::Update => "update",
12684        Action::Delete => "delete",
12685        Action::Truncate => "truncate",
12686        Action::References => "references",
12687        Action::Execute => "execute",
12688        Action::Usage => "usage",
12689        Action::All => "*",
12690    }
12691}
12692
12693fn update_set_target_columns(query: &crate::storage::query::ast::UpdateQuery) -> Vec<String> {
12694    let mut columns = Vec::new();
12695    for (column, _) in &query.assignment_exprs {
12696        if !columns.iter().any(|seen| seen == column) {
12697            columns.push(column.clone());
12698        }
12699    }
12700    columns
12701}
12702
12703fn column_access_request_for_table_update(
12704    table_name: &str,
12705    columns: Vec<String>,
12706) -> crate::auth::ColumnAccessRequest {
12707    match table_name.split_once('.') {
12708        Some((schema, table)) => {
12709            crate::auth::ColumnAccessRequest::update(table.to_string(), columns)
12710                .with_schema(schema.to_string())
12711        }
12712        None => crate::auth::ColumnAccessRequest::update(table_name.to_string(), columns),
12713    }
12714}
12715
12716fn column_access_request_for_table_select(
12717    table_name: &str,
12718    columns: Vec<String>,
12719) -> crate::auth::ColumnAccessRequest {
12720    match table_name.split_once('.') {
12721        Some((schema, table)) => {
12722            crate::auth::ColumnAccessRequest::select(table.to_string(), columns)
12723                .with_schema(schema.to_string())
12724        }
12725        None => crate::auth::ColumnAccessRequest::select(table_name.to_string(), columns),
12726    }
12727}
12728
12729fn update_returning_columns_for_policy(
12730    runtime: &RedDBRuntime,
12731    query: &crate::storage::query::ast::UpdateQuery,
12732) -> Option<Vec<String>> {
12733    let items = query.returning.as_ref()?;
12734    let mut columns = Vec::new();
12735    let project_all = items
12736        .iter()
12737        .any(|item| matches!(item, crate::storage::query::ast::ReturningItem::All));
12738    if project_all {
12739        collect_returning_star_columns(runtime, query, &mut columns);
12740    } else {
12741        for item in items {
12742            let crate::storage::query::ast::ReturningItem::Column(column) = item else {
12743                continue;
12744            };
12745            push_returning_policy_column(&mut columns, column);
12746        }
12747    }
12748    (!columns.is_empty()).then_some(columns)
12749}
12750
12751fn collect_returning_star_columns(
12752    runtime: &RedDBRuntime,
12753    query: &crate::storage::query::ast::UpdateQuery,
12754    columns: &mut Vec<String>,
12755) {
12756    let store = runtime.db().store();
12757    let Some(manager) = store.get_collection(&query.table) else {
12758        return;
12759    };
12760    if let Some(schema) = manager.column_schema() {
12761        for column in schema.iter() {
12762            push_returning_policy_column(columns, column);
12763        }
12764    }
12765    for entity in manager.query_all(|_| true) {
12766        if !returning_entity_matches_update_target(&entity, query.target) {
12767            continue;
12768        }
12769        match &entity.data {
12770            crate::storage::EntityData::Row(row) => {
12771                for (column, _) in row.iter_fields() {
12772                    push_returning_policy_column(columns, column);
12773                }
12774            }
12775            crate::storage::EntityData::Node(node) => {
12776                push_returning_policy_column(columns, "label");
12777                push_returning_policy_column(columns, "node_type");
12778                for column in node.properties.keys() {
12779                    push_returning_policy_column(columns, column);
12780                }
12781            }
12782            crate::storage::EntityData::Edge(edge) => {
12783                push_returning_policy_column(columns, "label");
12784                push_returning_policy_column(columns, "from_rid");
12785                push_returning_policy_column(columns, "to_rid");
12786                push_returning_policy_column(columns, "weight");
12787                for column in edge.properties.keys() {
12788                    push_returning_policy_column(columns, column);
12789                }
12790            }
12791            _ => {}
12792        }
12793    }
12794}
12795
12796fn push_returning_policy_column(columns: &mut Vec<String>, column: &str) {
12797    if returning_public_envelope_column(column) {
12798        return;
12799    }
12800    if !columns.iter().any(|seen| seen == column) {
12801        columns.push(column.to_string());
12802    }
12803}
12804
12805fn returning_public_envelope_column(column: &str) -> bool {
12806    matches!(
12807        column.to_ascii_lowercase().as_str(),
12808        "rid" | "collection" | "kind" | "tenant" | "created_at" | "updated_at"
12809    )
12810}
12811
12812fn returning_entity_matches_update_target(
12813    entity: &crate::storage::UnifiedEntity,
12814    target: crate::storage::query::ast::UpdateTarget,
12815) -> bool {
12816    use crate::storage::query::ast::UpdateTarget;
12817    match target {
12818        UpdateTarget::Rows => {
12819            matches!(returning_row_item_kind(entity), Some(ReturningRowKind::Row))
12820        }
12821        UpdateTarget::Documents => {
12822            matches!(
12823                returning_row_item_kind(entity),
12824                Some(ReturningRowKind::Document)
12825            )
12826        }
12827        UpdateTarget::Kv => matches!(returning_row_item_kind(entity), Some(ReturningRowKind::Kv)),
12828        UpdateTarget::Nodes => matches!(
12829            (&entity.kind, &entity.data),
12830            (
12831                crate::storage::EntityKind::GraphNode(_),
12832                crate::storage::EntityData::Node(_)
12833            )
12834        ),
12835        UpdateTarget::Edges => matches!(
12836            (&entity.kind, &entity.data),
12837            (
12838                crate::storage::EntityKind::GraphEdge(_),
12839                crate::storage::EntityData::Edge(_)
12840            )
12841        ),
12842    }
12843}
12844
12845#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12846enum ReturningRowKind {
12847    Row,
12848    Document,
12849    Kv,
12850}
12851
12852fn returning_row_item_kind(entity: &crate::storage::UnifiedEntity) -> Option<ReturningRowKind> {
12853    let row = entity.data.as_row()?;
12854    let is_kv = row.iter_fields().all(|(column, _)| {
12855        column.eq_ignore_ascii_case("key") || column.eq_ignore_ascii_case("value")
12856    });
12857    if is_kv {
12858        return Some(ReturningRowKind::Kv);
12859    }
12860    let is_document = row
12861        .iter_fields()
12862        .any(|(_, value)| matches!(value, crate::storage::schema::Value::Json(_)));
12863    if is_document {
12864        Some(ReturningRowKind::Document)
12865    } else {
12866        Some(ReturningRowKind::Row)
12867    }
12868}
12869
12870fn requested_table_columns_for_policy(
12871    table: &crate::storage::query::ast::TableQuery,
12872) -> Vec<String> {
12873    use crate::storage::query::sql_lowering::{
12874        effective_table_filter, effective_table_group_by_exprs, effective_table_having_filter,
12875        effective_table_projections,
12876    };
12877
12878    let table_name = table.table.as_str();
12879    let table_alias = table.alias.as_deref();
12880    let mut columns = std::collections::BTreeSet::new();
12881
12882    for projection in effective_table_projections(table) {
12883        collect_projection_columns(&projection, table_name, table_alias, &mut columns);
12884    }
12885    if let Some(filter) = effective_table_filter(table) {
12886        collect_filter_columns(&filter, table_name, table_alias, &mut columns);
12887    }
12888    for expr in effective_table_group_by_exprs(table) {
12889        collect_expr_columns(&expr, table_name, table_alias, &mut columns);
12890    }
12891    if let Some(filter) = effective_table_having_filter(table) {
12892        collect_filter_columns(&filter, table_name, table_alias, &mut columns);
12893    }
12894    for order in &table.order_by {
12895        if let Some(expr) = order.expr.as_ref() {
12896            collect_expr_columns(expr, table_name, table_alias, &mut columns);
12897        } else {
12898            collect_field_ref_column(&order.field, table_name, table_alias, &mut columns);
12899        }
12900    }
12901
12902    columns.into_iter().collect()
12903}
12904
12905fn collect_projection_columns(
12906    projection: &crate::storage::query::ast::Projection,
12907    table_name: &str,
12908    table_alias: Option<&str>,
12909    columns: &mut std::collections::BTreeSet<String>,
12910) {
12911    use crate::storage::query::ast::Projection;
12912    match projection {
12913        Projection::All => {
12914            columns.insert("*".to_string());
12915        }
12916        Projection::Column(column) | Projection::Alias(column, _) => {
12917            if column != "*" {
12918                columns.insert(column.clone());
12919            }
12920        }
12921        Projection::Function(_, args) => {
12922            for arg in args {
12923                collect_projection_columns(arg, table_name, table_alias, columns);
12924            }
12925        }
12926        Projection::Expression(filter, _) => {
12927            collect_filter_columns(filter, table_name, table_alias, columns);
12928        }
12929        Projection::Field(field, _) => {
12930            collect_field_ref_column(field, table_name, table_alias, columns);
12931        }
12932        // Slice 7a (#589): no runtime support yet; recurse into args so
12933        // any column references are still tracked in case a future
12934        // executor needs the column set.
12935        Projection::Window { args, .. } => {
12936            for arg in args {
12937                collect_projection_columns(arg, table_name, table_alias, columns);
12938            }
12939        }
12940    }
12941}
12942
12943fn collect_filter_columns(
12944    filter: &crate::storage::query::ast::Filter,
12945    table_name: &str,
12946    table_alias: Option<&str>,
12947    columns: &mut std::collections::BTreeSet<String>,
12948) {
12949    use crate::storage::query::ast::Filter;
12950    match filter {
12951        Filter::Compare { field, .. }
12952        | Filter::IsNull(field)
12953        | Filter::IsNotNull(field)
12954        | Filter::In { field, .. }
12955        | Filter::Between { field, .. }
12956        | Filter::Like { field, .. }
12957        | Filter::StartsWith { field, .. }
12958        | Filter::EndsWith { field, .. }
12959        | Filter::Contains { field, .. } => {
12960            collect_field_ref_column(field, table_name, table_alias, columns);
12961        }
12962        Filter::CompareFields { left, right, .. } => {
12963            collect_field_ref_column(left, table_name, table_alias, columns);
12964            collect_field_ref_column(right, table_name, table_alias, columns);
12965        }
12966        Filter::CompareExpr { lhs, rhs, .. } => {
12967            collect_expr_columns(lhs, table_name, table_alias, columns);
12968            collect_expr_columns(rhs, table_name, table_alias, columns);
12969        }
12970        Filter::And(left, right) | Filter::Or(left, right) => {
12971            collect_filter_columns(left, table_name, table_alias, columns);
12972            collect_filter_columns(right, table_name, table_alias, columns);
12973        }
12974        Filter::Not(inner) => collect_filter_columns(inner, table_name, table_alias, columns),
12975    }
12976}
12977
12978fn collect_expr_columns(
12979    expr: &crate::storage::query::ast::Expr,
12980    table_name: &str,
12981    table_alias: Option<&str>,
12982    columns: &mut std::collections::BTreeSet<String>,
12983) {
12984    use crate::storage::query::ast::Expr;
12985    match expr {
12986        Expr::Column { field, .. } => {
12987            collect_field_ref_column(field, table_name, table_alias, columns);
12988        }
12989        Expr::Literal { .. } | Expr::Parameter { .. } => {}
12990        Expr::UnaryOp { operand, .. } | Expr::Cast { inner: operand, .. } => {
12991            collect_expr_columns(operand, table_name, table_alias, columns);
12992        }
12993        Expr::BinaryOp { lhs, rhs, .. } => {
12994            collect_expr_columns(lhs, table_name, table_alias, columns);
12995            collect_expr_columns(rhs, table_name, table_alias, columns);
12996        }
12997        Expr::FunctionCall { args, .. } => {
12998            for arg in args {
12999                collect_expr_columns(arg, table_name, table_alias, columns);
13000            }
13001        }
13002        Expr::Case {
13003            branches, else_, ..
13004        } => {
13005            for (condition, value) in branches {
13006                collect_expr_columns(condition, table_name, table_alias, columns);
13007                collect_expr_columns(value, table_name, table_alias, columns);
13008            }
13009            if let Some(value) = else_ {
13010                collect_expr_columns(value, table_name, table_alias, columns);
13011            }
13012        }
13013        Expr::IsNull { operand, .. } => {
13014            collect_expr_columns(operand, table_name, table_alias, columns);
13015        }
13016        Expr::InList { target, values, .. } => {
13017            collect_expr_columns(target, table_name, table_alias, columns);
13018            for value in values {
13019                collect_expr_columns(value, table_name, table_alias, columns);
13020            }
13021        }
13022        Expr::Between {
13023            target, low, high, ..
13024        } => {
13025            collect_expr_columns(target, table_name, table_alias, columns);
13026            collect_expr_columns(low, table_name, table_alias, columns);
13027            collect_expr_columns(high, table_name, table_alias, columns);
13028        }
13029        Expr::Subquery { .. } => {}
13030        Expr::WindowFunctionCall { args, window, .. } => {
13031            for arg in args {
13032                collect_expr_columns(arg, table_name, table_alias, columns);
13033            }
13034            for e in &window.partition_by {
13035                collect_expr_columns(e, table_name, table_alias, columns);
13036            }
13037            for o in &window.order_by {
13038                collect_expr_columns(&o.expr, table_name, table_alias, columns);
13039            }
13040        }
13041    }
13042}
13043
13044fn collect_field_ref_column(
13045    field: &crate::storage::query::ast::FieldRef,
13046    table_name: &str,
13047    table_alias: Option<&str>,
13048    columns: &mut std::collections::BTreeSet<String>,
13049) {
13050    if let Some(column) = policy_column_name_from_field_ref(field, table_name, table_alias) {
13051        if column != "*" {
13052            columns.insert(column);
13053        }
13054    }
13055}
13056
13057fn policy_column_name_from_field_ref(
13058    field: &crate::storage::query::ast::FieldRef,
13059    table_name: &str,
13060    table_alias: Option<&str>,
13061) -> Option<String> {
13062    match field {
13063        crate::storage::query::ast::FieldRef::TableColumn { table, column } => {
13064            if column == "*" {
13065                return Some("*".to_string());
13066            }
13067            if table.is_empty() || table == table_name || Some(table.as_str()) == table_alias {
13068                Some(column.clone())
13069            } else {
13070                Some(format!("{table}.{column}"))
13071            }
13072        }
13073        _ => None,
13074    }
13075}
13076
13077fn legacy_resource_to_iam(
13078    resource: &crate::auth::privileges::Resource,
13079    tenant: Option<&str>,
13080) -> crate::auth::policies::ResourceRef {
13081    use crate::auth::privileges::Resource;
13082
13083    let (kind, name) = match resource {
13084        Resource::Database => ("database".to_string(), "*".to_string()),
13085        Resource::Schema(s) => ("schema".to_string(), format!("{s}.*")),
13086        Resource::Table { schema, table } => (
13087            "table".to_string(),
13088            match schema {
13089                Some(s) => format!("{s}.{table}"),
13090                None => table.clone(),
13091            },
13092        ),
13093        Resource::Function { schema, name } => (
13094            "function".to_string(),
13095            match schema {
13096                Some(s) => format!("{s}.{name}"),
13097                None => name.clone(),
13098            },
13099        ),
13100    };
13101
13102    let mut out = crate::auth::policies::ResourceRef::new(kind, name);
13103    if let Some(t) = tenant {
13104        out = out.with_tenant(t.to_string());
13105    }
13106    out
13107}
13108
13109#[derive(Debug)]
13110struct JoinTableSide {
13111    table: String,
13112    alias: String,
13113}
13114
13115fn table_side_context(expr: &QueryExpr) -> Option<JoinTableSide> {
13116    match expr {
13117        QueryExpr::Table(table) => Some(JoinTableSide {
13118            table: table.table.clone(),
13119            alias: table.alias.clone().unwrap_or_else(|| table.table.clone()),
13120        }),
13121        _ => None,
13122    }
13123}
13124
13125fn collect_projection_columns_for_table(
13126    projection: &Projection,
13127    table: &str,
13128    alias: Option<&str>,
13129    out: &mut BTreeSet<String>,
13130) {
13131    match projection {
13132        Projection::Column(column) | Projection::Alias(column, _) => {
13133            match split_qualified_column(column) {
13134                Some((qualifier, column))
13135                    if qualifier == table || alias.is_some_and(|alias| qualifier == alias) =>
13136                {
13137                    push_policy_column(column, out);
13138                }
13139                Some(_) => {}
13140                None => push_policy_column(column, out),
13141            }
13142        }
13143        Projection::Field(
13144            FieldRef::TableColumn {
13145                table: qualifier,
13146                column,
13147            },
13148            _,
13149        ) => {
13150            if qualifier.is_empty()
13151                || qualifier == table
13152                || alias.is_some_and(|alias| qualifier == alias)
13153            {
13154                push_policy_column(column, out);
13155            }
13156        }
13157        Projection::Field(
13158            FieldRef::NodeProperty {
13159                alias: qualifier,
13160                property,
13161            },
13162            _,
13163        )
13164        | Projection::Field(
13165            FieldRef::EdgeProperty {
13166                alias: qualifier,
13167                property,
13168            },
13169            _,
13170        ) => {
13171            if qualifier == table || alias.is_some_and(|alias| qualifier == alias) {
13172                push_policy_column(property, out);
13173            }
13174        }
13175        Projection::Function(_, args) => {
13176            for arg in args {
13177                collect_projection_columns_for_table(arg, table, alias, out);
13178            }
13179        }
13180        Projection::Expression(_, _) | Projection::All | Projection::Field(_, _) => {}
13181        Projection::Window { args, .. } => {
13182            for arg in args {
13183                collect_projection_columns_for_table(arg, table, alias, out);
13184            }
13185        }
13186    }
13187}
13188
13189fn collect_projection_columns_for_join_side(
13190    projection: &Projection,
13191    left: Option<&JoinTableSide>,
13192    right: Option<&JoinTableSide>,
13193    out: &mut HashMap<String, BTreeSet<String>>,
13194) -> RedDBResult<()> {
13195    match projection {
13196        Projection::Column(column) | Projection::Alias(column, _) => {
13197            if let Some((qualifier, column)) = split_qualified_column(column) {
13198                push_qualified_join_column(qualifier, column, left, right, out);
13199            } else {
13200                push_unqualified_join_column(column, left, right, out);
13201            }
13202        }
13203        Projection::Field(FieldRef::TableColumn { table, column }, _) => {
13204            if table.is_empty() {
13205                push_unqualified_join_column(column, left, right, out);
13206            } else if let Some(side) = [left, right]
13207                .into_iter()
13208                .flatten()
13209                .find(|side| table == side.table.as_str() || table == side.alias.as_str())
13210            {
13211                push_join_column(&side.table, column, out);
13212            }
13213        }
13214        Projection::Field(FieldRef::NodeProperty { alias, property }, _)
13215        | Projection::Field(FieldRef::EdgeProperty { alias, property }, _) => {
13216            push_qualified_join_column(alias, property, left, right, out);
13217        }
13218        Projection::Function(_, args) => {
13219            for arg in args {
13220                collect_projection_columns_for_join_side(arg, left, right, out)?;
13221            }
13222        }
13223        Projection::Expression(_, _) | Projection::All | Projection::Field(_, _) => {}
13224        Projection::Window { args, .. } => {
13225            for arg in args {
13226                collect_projection_columns_for_join_side(arg, left, right, out)?;
13227            }
13228        }
13229    }
13230    Ok(())
13231}
13232
13233fn split_qualified_column(column: &str) -> Option<(&str, &str)> {
13234    let (qualifier, column) = column.split_once('.')?;
13235    if qualifier.is_empty() || column.is_empty() || column.contains('.') {
13236        return None;
13237    }
13238    Some((qualifier, column))
13239}
13240
13241fn push_qualified_join_column(
13242    qualifier: &str,
13243    column: &str,
13244    left: Option<&JoinTableSide>,
13245    right: Option<&JoinTableSide>,
13246    out: &mut HashMap<String, BTreeSet<String>>,
13247) {
13248    if let Some(side) = [left, right]
13249        .into_iter()
13250        .flatten()
13251        .find(|side| qualifier == side.table.as_str() || qualifier == side.alias.as_str())
13252    {
13253        push_join_column(&side.table, column, out);
13254    }
13255}
13256
13257fn push_unqualified_join_column(
13258    column: &str,
13259    left: Option<&JoinTableSide>,
13260    right: Option<&JoinTableSide>,
13261    out: &mut HashMap<String, BTreeSet<String>>,
13262) {
13263    for side in [left, right].into_iter().flatten() {
13264        push_join_column(&side.table, column, out);
13265    }
13266}
13267
13268fn push_join_column(table: &str, column: &str, out: &mut HashMap<String, BTreeSet<String>>) {
13269    if is_policy_column_name(column) {
13270        out.entry(table.to_string())
13271            .or_default()
13272            .insert(column.to_string());
13273    }
13274}
13275
13276fn push_policy_column(column: &str, out: &mut BTreeSet<String>) {
13277    if is_policy_column_name(column) {
13278        out.insert(column.to_string());
13279    }
13280}
13281
13282fn is_policy_column_name(column: &str) -> bool {
13283    !column.is_empty()
13284        && column != "*"
13285        && !column.starts_with("LIT:")
13286        && !column.starts_with("TYPE:")
13287}
13288
13289fn runtime_iam_context(
13290    role: crate::auth::Role,
13291    tenant: Option<&str>,
13292) -> crate::auth::policies::EvalContext {
13293    crate::auth::policies::EvalContext {
13294        principal_tenant: tenant.map(|t| t.to_string()),
13295        current_tenant: tenant.map(|t| t.to_string()),
13296        peer_ip: None,
13297        mfa_present: false,
13298        now_ms: crate::auth::now_ms(),
13299        principal_is_admin_role: role == crate::auth::Role::Admin,
13300        principal_is_platform_scoped: tenant.is_none(),
13301    }
13302}
13303
13304fn explicit_table_projection_columns(
13305    query: &crate::storage::query::ast::TableQuery,
13306) -> Vec<String> {
13307    use crate::storage::query::ast::{FieldRef, Projection};
13308
13309    let mut columns = Vec::new();
13310    for projection in crate::storage::query::sql_lowering::effective_table_projections(query) {
13311        match projection {
13312            Projection::Column(column) | Projection::Alias(column, _) => {
13313                push_unique(&mut columns, column)
13314            }
13315            Projection::Field(FieldRef::TableColumn { column, .. }, _) => {
13316                push_unique(&mut columns, column)
13317            }
13318            // SELECT * and expression/function projections need the
13319            // executor-wide column-policy context mapped in
13320            // docs/security/select-relational-column-policy-audit-2026-05-08.md.
13321            _ => {}
13322        }
13323    }
13324    columns
13325}
13326
13327fn explicit_graph_projection_properties(
13328    query: &crate::storage::query::ast::GraphQuery,
13329) -> Vec<String> {
13330    use crate::storage::query::ast::{FieldRef, Projection};
13331
13332    let mut columns = Vec::new();
13333    for projection in &query.return_ {
13334        match projection {
13335            Projection::Field(FieldRef::NodeProperty { property, .. }, _)
13336            | Projection::Field(FieldRef::EdgeProperty { property, .. }, _) => {
13337                push_unique(&mut columns, property.clone())
13338            }
13339            _ => {}
13340        }
13341    }
13342    columns
13343}
13344
13345fn push_unique(columns: &mut Vec<String>, column: String) {
13346    if !columns.iter().any(|existing| existing == &column) {
13347        columns.push(column);
13348    }
13349}
13350
13351fn principal_label(p: &crate::storage::query::ast::PolicyPrincipalRef) -> String {
13352    use crate::storage::query::ast::PolicyPrincipalRef;
13353    match p {
13354        PolicyPrincipalRef::User(u) => match &u.tenant {
13355            Some(t) => format!("user:{t}/{}", u.username),
13356            None => format!("user:{}", u.username),
13357        },
13358        PolicyPrincipalRef::Group(g) => format!("group:{g}"),
13359    }
13360}
13361
13362/// Render a `Decision` into the (decision, matched_policy_id, matched_sid)
13363/// shape used by every audit emit + the simulator response.
13364pub(crate) fn decision_to_strings(
13365    d: &crate::auth::policies::Decision,
13366) -> (String, Option<String>, Option<String>) {
13367    use crate::auth::policies::Decision;
13368    match d {
13369        Decision::Allow {
13370            matched_policy_id,
13371            matched_sid,
13372        } => (
13373            "allow".into(),
13374            Some(matched_policy_id.clone()),
13375            matched_sid.clone(),
13376        ),
13377        Decision::Deny {
13378            matched_policy_id,
13379            matched_sid,
13380        } => (
13381            "deny".into(),
13382            Some(matched_policy_id.clone()),
13383            matched_sid.clone(),
13384        ),
13385        Decision::DefaultDeny => ("default_deny".into(), None, None),
13386        Decision::AdminBypass => ("admin_bypass".into(), None, None),
13387    }
13388}
13389
13390fn relation_scopes_for_query(query: &QueryExpr) -> Vec<String> {
13391    let mut scopes = Vec::new();
13392    collect_relation_scopes(query, &mut scopes);
13393    scopes.sort();
13394    scopes.dedup();
13395    scopes
13396}
13397
13398fn collect_relation_scopes(query: &QueryExpr, scopes: &mut Vec<String>) {
13399    match query {
13400        QueryExpr::Table(table) => {
13401            if !table.table.is_empty() {
13402                scopes.push(table.table.clone());
13403            }
13404            if let Some(alias) = &table.alias {
13405                scopes.push(alias.clone());
13406            }
13407        }
13408        QueryExpr::Join(join) => {
13409            collect_relation_scopes(&join.left, scopes);
13410            collect_relation_scopes(&join.right, scopes);
13411        }
13412        _ => {}
13413    }
13414}
13415
13416fn query_references_outer_scope(query: &QueryExpr, outer_scopes: &[String]) -> bool {
13417    let inner_scopes = relation_scopes_for_query(query);
13418    query_expr_references_outer_scope(query, outer_scopes, &inner_scopes)
13419}
13420
13421fn query_expr_references_outer_scope(
13422    query: &QueryExpr,
13423    outer_scopes: &[String],
13424    inner_scopes: &[String],
13425) -> bool {
13426    match query {
13427        QueryExpr::Table(table) => {
13428            table.select_items.iter().any(|item| match item {
13429                crate::storage::query::ast::SelectItem::Wildcard => false,
13430                crate::storage::query::ast::SelectItem::Expr { expr, .. } => {
13431                    expr_references_outer_scope(expr, outer_scopes, inner_scopes)
13432                }
13433            }) || table
13434                .where_expr
13435                .as_ref()
13436                .is_some_and(|expr| expr_references_outer_scope(expr, outer_scopes, inner_scopes))
13437                || table.filter.as_ref().is_some_and(|filter| {
13438                    filter_references_outer_scope(filter, outer_scopes, inner_scopes)
13439                })
13440                || table.having_expr.as_ref().is_some_and(|expr| {
13441                    expr_references_outer_scope(expr, outer_scopes, inner_scopes)
13442                })
13443                || table.having.as_ref().is_some_and(|filter| {
13444                    filter_references_outer_scope(filter, outer_scopes, inner_scopes)
13445                })
13446                || table
13447                    .group_by_exprs
13448                    .iter()
13449                    .any(|expr| expr_references_outer_scope(expr, outer_scopes, inner_scopes))
13450                || table.order_by.iter().any(|clause| {
13451                    clause.expr.as_ref().is_some_and(|expr| {
13452                        expr_references_outer_scope(expr, outer_scopes, inner_scopes)
13453                    })
13454                })
13455        }
13456        QueryExpr::Join(join) => {
13457            query_expr_references_outer_scope(&join.left, outer_scopes, inner_scopes)
13458                || query_expr_references_outer_scope(&join.right, outer_scopes, inner_scopes)
13459                || join.filter.as_ref().is_some_and(|filter| {
13460                    filter_references_outer_scope(filter, outer_scopes, inner_scopes)
13461                })
13462                || join.return_items.iter().any(|item| match item {
13463                    crate::storage::query::ast::SelectItem::Wildcard => false,
13464                    crate::storage::query::ast::SelectItem::Expr { expr, .. } => {
13465                        expr_references_outer_scope(expr, outer_scopes, inner_scopes)
13466                    }
13467                })
13468        }
13469        _ => false,
13470    }
13471}
13472
13473fn filter_references_outer_scope(
13474    filter: &crate::storage::query::ast::Filter,
13475    outer_scopes: &[String],
13476    inner_scopes: &[String],
13477) -> bool {
13478    use crate::storage::query::ast::Filter;
13479    match filter {
13480        Filter::Compare { field, .. }
13481        | Filter::IsNull(field)
13482        | Filter::IsNotNull(field)
13483        | Filter::In { field, .. }
13484        | Filter::Between { field, .. }
13485        | Filter::Like { field, .. }
13486        | Filter::StartsWith { field, .. }
13487        | Filter::EndsWith { field, .. }
13488        | Filter::Contains { field, .. } => {
13489            field_ref_references_outer_scope(field, outer_scopes, inner_scopes)
13490        }
13491        Filter::CompareFields { left, right, .. } => {
13492            field_ref_references_outer_scope(left, outer_scopes, inner_scopes)
13493                || field_ref_references_outer_scope(right, outer_scopes, inner_scopes)
13494        }
13495        Filter::CompareExpr { lhs, rhs, .. } => {
13496            expr_references_outer_scope(lhs, outer_scopes, inner_scopes)
13497                || expr_references_outer_scope(rhs, outer_scopes, inner_scopes)
13498        }
13499        Filter::And(left, right) | Filter::Or(left, right) => {
13500            filter_references_outer_scope(left, outer_scopes, inner_scopes)
13501                || filter_references_outer_scope(right, outer_scopes, inner_scopes)
13502        }
13503        Filter::Not(inner) => filter_references_outer_scope(inner, outer_scopes, inner_scopes),
13504    }
13505}
13506
13507fn expr_references_outer_scope(
13508    expr: &crate::storage::query::ast::Expr,
13509    outer_scopes: &[String],
13510    inner_scopes: &[String],
13511) -> bool {
13512    use crate::storage::query::ast::Expr;
13513    match expr {
13514        Expr::Column { field, .. } => {
13515            field_ref_references_outer_scope(field, outer_scopes, inner_scopes)
13516        }
13517        Expr::BinaryOp { lhs, rhs, .. } => {
13518            expr_references_outer_scope(lhs, outer_scopes, inner_scopes)
13519                || expr_references_outer_scope(rhs, outer_scopes, inner_scopes)
13520        }
13521        Expr::UnaryOp { operand, .. }
13522        | Expr::Cast { inner: operand, .. }
13523        | Expr::IsNull { operand, .. } => {
13524            expr_references_outer_scope(operand, outer_scopes, inner_scopes)
13525        }
13526        Expr::FunctionCall { args, .. } => args
13527            .iter()
13528            .any(|arg| expr_references_outer_scope(arg, outer_scopes, inner_scopes)),
13529        Expr::Case {
13530            branches, else_, ..
13531        } => {
13532            branches.iter().any(|(cond, value)| {
13533                expr_references_outer_scope(cond, outer_scopes, inner_scopes)
13534                    || expr_references_outer_scope(value, outer_scopes, inner_scopes)
13535            }) || else_
13536                .as_ref()
13537                .is_some_and(|expr| expr_references_outer_scope(expr, outer_scopes, inner_scopes))
13538        }
13539        Expr::InList { target, values, .. } => {
13540            expr_references_outer_scope(target, outer_scopes, inner_scopes)
13541                || values
13542                    .iter()
13543                    .any(|value| expr_references_outer_scope(value, outer_scopes, inner_scopes))
13544        }
13545        Expr::Between {
13546            target, low, high, ..
13547        } => {
13548            expr_references_outer_scope(target, outer_scopes, inner_scopes)
13549                || expr_references_outer_scope(low, outer_scopes, inner_scopes)
13550                || expr_references_outer_scope(high, outer_scopes, inner_scopes)
13551        }
13552        Expr::Subquery { query, .. } => query_references_outer_scope(&query.query, inner_scopes),
13553        Expr::Literal { .. } | Expr::Parameter { .. } => false,
13554        Expr::WindowFunctionCall { args, window, .. } => {
13555            args.iter()
13556                .any(|arg| expr_references_outer_scope(arg, outer_scopes, inner_scopes))
13557                || window
13558                    .partition_by
13559                    .iter()
13560                    .any(|e| expr_references_outer_scope(e, outer_scopes, inner_scopes))
13561                || window
13562                    .order_by
13563                    .iter()
13564                    .any(|o| expr_references_outer_scope(&o.expr, outer_scopes, inner_scopes))
13565        }
13566    }
13567}
13568
13569fn field_ref_references_outer_scope(
13570    field: &crate::storage::query::ast::FieldRef,
13571    outer_scopes: &[String],
13572    inner_scopes: &[String],
13573) -> bool {
13574    match field {
13575        crate::storage::query::ast::FieldRef::TableColumn { table, .. } if !table.is_empty() => {
13576            outer_scopes.iter().any(|scope| scope == table)
13577                && !inner_scopes.iter().any(|scope| scope == table)
13578        }
13579        _ => false,
13580    }
13581}
13582
13583fn first_column_values(
13584    result: crate::storage::query::unified::UnifiedResult,
13585) -> RedDBResult<Vec<Value>> {
13586    if result.columns.len() > 1 {
13587        return Err(RedDBError::Query(
13588            "expression subquery must return exactly one column".to_string(),
13589        ));
13590    }
13591    let fallback_column = result
13592        .records
13593        .first()
13594        .and_then(|record| record.column_names().into_iter().next())
13595        .map(|name| name.to_string());
13596    let column = result.columns.first().cloned().or(fallback_column);
13597    let Some(column) = column else {
13598        return Ok(Vec::new());
13599    };
13600    Ok(result
13601        .records
13602        .iter()
13603        .map(|record| record.get(column.as_str()).cloned().unwrap_or(Value::Null))
13604        .collect())
13605}
13606
13607fn parse_timestamp_to_ms(s: &str) -> Option<u128> {
13608    // Bare integer ms.
13609    if let Ok(n) = s.parse::<u128>() {
13610        return Some(n);
13611    }
13612    // Fallback: ISO-8601 like 2030-01-02 03:04:05 — accept the date
13613    // portion only (midnight UTC). Full RFC3339 parsing is a stretch
13614    // goal; the common case is `'2030-01-01'`.
13615    if let Some(date) = s.split_whitespace().next() {
13616        let parts: Vec<&str> = date.split('-').collect();
13617        if parts.len() == 3 {
13618            let (y, m, d) = (parts[0], parts[1], parts[2]);
13619            if let (Ok(y), Ok(m), Ok(d)) = (y.parse::<i64>(), m.parse::<u32>(), d.parse::<u32>()) {
13620                // Days since 1970-01-01 — simple Julian arithmetic
13621                // suitable for years 1970-2100. Good enough for test
13622                // fixtures; precise parsing lands when we wire chrono.
13623                let days_in = days_from_civil(y, m, d);
13624                return Some((days_in as u128) * 86_400_000u128);
13625            }
13626        }
13627    }
13628    None
13629}
13630
13631/// Days from Unix epoch using H. Hinnant's civil-from-days algorithm.
13632/// Robust for the entire Gregorian range; used by `parse_timestamp_to_ms`.
13633fn days_from_civil(y: i64, m: u32, d: u32) -> i64 {
13634    let y = if m <= 2 { y - 1 } else { y };
13635    let era = if y >= 0 { y } else { y - 399 } / 400;
13636    let yoe = (y - era * 400) as u64; // [0, 399]
13637    let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) as u64 + 2) / 5 + d as u64 - 1;
13638    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
13639    era * 146097 + doe as i64 - 719468
13640}
13641
13642fn walk_plan_node(
13643    node: &crate::storage::query::planner::CanonicalLogicalNode,
13644    depth: usize,
13645    out: &mut Vec<crate::storage::query::unified::UnifiedRecord>,
13646) {
13647    use std::sync::Arc;
13648    let mut rec = crate::storage::query::unified::UnifiedRecord::default();
13649    rec.set_arc(Arc::from("op"), Value::text(node.operator.clone()));
13650    rec.set_arc(
13651        Arc::from("source"),
13652        node.source.clone().map(Value::text).unwrap_or(Value::Null),
13653    );
13654    rec.set_arc(Arc::from("est_rows"), Value::Float(node.estimated_rows));
13655    rec.set_arc(Arc::from("est_cost"), Value::Float(node.operator_cost));
13656    rec.set_arc(Arc::from("depth"), Value::Integer(depth as i64));
13657    out.push(rec);
13658    for child in &node.children {
13659        walk_plan_node(child, depth + 1, out);
13660    }
13661}
13662
13663#[cfg(test)]
13664mod inline_graph_tvf_tests {
13665    use super::*;
13666
13667    fn scopes_for(sql: &str) -> HashSet<String> {
13668        let expr = crate::storage::query::parser::parse(sql)
13669            .expect("parse")
13670            .query;
13671        query_expr_result_cache_scopes(&expr)
13672    }
13673
13674    #[test]
13675    fn inline_tvf_cache_scopes_include_source_collections() {
13676        // The result-cache key for the inline form must derive from the
13677        // `nodes`/`edges` source collections so a write to either invalidates
13678        // the cached result (issue #799).
13679        let scopes = scopes_for(
13680            "SELECT * FROM components(nodes => (SELECT id FROM hosts), edges => (SELECT src, dst FROM links))",
13681        );
13682        assert!(scopes.contains("hosts"), "nodes source scoped: {scopes:?}");
13683        assert!(scopes.contains("links"), "edges source scoped: {scopes:?}");
13684    }
13685
13686    #[test]
13687    fn graph_collection_tvf_cache_scope_is_graph_argument() {
13688        // The graph-collection form still materializes the active graph, but
13689        // result-cache invalidation is scoped to the named graph argument so
13690        // INSERT INTO g NODE/EDGE invalidates cached TVF rows.
13691        let scopes = scopes_for("SELECT * FROM components(g)");
13692        assert!(scopes.contains("g"), "collection form scoped: {scopes:?}");
13693    }
13694
13695    #[test]
13696    fn abstract_degree_centrality_counts_undirected_endpoints() {
13697        let nodes = vec!["a".to_string(), "b".to_string(), "c".to_string()];
13698        let edges = vec![
13699            ("a".to_string(), "b".to_string(), 1.0_f32),
13700            ("b".to_string(), "c".to_string(), 1.0_f32),
13701        ];
13702        let degrees = abstract_degree_centrality(&nodes, &edges);
13703        assert_eq!(
13704            degrees,
13705            vec![
13706                ("a".to_string(), 1),
13707                ("b".to_string(), 2),
13708                ("c".to_string(), 1),
13709            ]
13710        );
13711    }
13712}