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 secret_sql_value_to_string(value: &Value) -> RedDBResult<String> {
58    match value {
59        Value::Text(s) => Ok(s.to_string()),
60        Value::Integer(n) => Ok(n.to_string()),
61        Value::UnsignedInteger(n) => Ok(n.to_string()),
62        Value::Float(n) => Ok(n.to_string()),
63        Value::Boolean(b) => Ok(b.to_string()),
64        Value::Null => Err(RedDBError::Query(
65            "SET SECRET key = NULL deletes the secret; use DELETE SECRET for explicit deletes"
66                .to_string(),
67        )),
68        Value::Password(_) | Value::Secret(_) => Err(RedDBError::Query(
69            "SET SECRET accepts plain scalar literals; PASSWORD() and SECRET() are for typed columns"
70                .to_string(),
71        )),
72        _ => Err(RedDBError::Query(format!(
73            "SET SECRET does not support value type {:?} yet",
74            value.data_type()
75        ))),
76    }
77}
78
79fn insert_config_json_path(
80    root: &mut crate::serde_json::Value,
81    path: &str,
82    value: crate::serde_json::Value,
83) {
84    let segments: Vec<&str> = path
85        .split('.')
86        .filter(|segment| !segment.is_empty())
87        .collect();
88    insert_config_json_segments(root, &segments, value);
89}
90
91fn insert_config_json_segments(
92    root: &mut crate::serde_json::Value,
93    segments: &[&str],
94    value: crate::serde_json::Value,
95) {
96    if segments.is_empty() {
97        *root = value;
98        return;
99    }
100
101    if !matches!(root, crate::serde_json::Value::Object(_)) {
102        *root = crate::serde_json::Value::Object(crate::serde_json::Map::new());
103    }
104
105    let crate::serde_json::Value::Object(map) = root else {
106        return;
107    };
108    if segments.len() == 1 {
109        map.insert(segments[0].to_string(), value);
110        return;
111    }
112    let entry = map
113        .entry(segments[0].to_string())
114        .or_insert_with(|| crate::serde_json::Value::Object(crate::serde_json::Map::new()));
115    insert_config_json_segments(entry, &segments[1..], value);
116}
117
118fn show_config_json_result(
119    query: &str,
120    mode: crate::storage::query::modes::QueryMode,
121    prefix: &Option<String>,
122    value: crate::serde_json::Value,
123) -> RuntimeQueryResult {
124    let mut result = UnifiedResult::with_columns(vec!["key".into(), "value".into()]);
125    let mut record = UnifiedRecord::new();
126    record.set(
127        "key",
128        prefix
129            .as_ref()
130            .map(|key| Value::text(key.clone()))
131            .unwrap_or(Value::Null),
132    );
133    record.set("value", Value::Json(value.to_string_compact().into_bytes()));
134    result.push(record);
135    RuntimeQueryResult {
136        query: query.to_string(),
137        mode,
138        statement: "show_config_json",
139        engine: "runtime-config",
140        result,
141        affected_rows: 0,
142        statement_type: "select",
143        bookmark: None,
144    }
145}
146
147#[derive(Clone)]
148struct QueryControlEventSpec {
149    kind: crate::runtime::control_events::EventKind,
150    action: &'static str,
151    resource: Option<String>,
152    fields: Vec<(String, crate::runtime::control_events::Sensitivity)>,
153}
154
155#[derive(Clone)]
156struct QueryAuditPlan {
157    statement_kind: &'static str,
158    collections: Vec<String>,
159}
160
161fn query_audit_plan(expr: &QueryExpr) -> Option<QueryAuditPlan> {
162    let mut collections = Vec::new();
163    let statement_kind = match expr {
164        QueryExpr::Table(table) => {
165            push_query_audit_collection(&mut collections, &table.table);
166            "select"
167        }
168        QueryExpr::Join(join) => {
169            collect_query_audit_collections(&join.left, &mut collections);
170            collect_query_audit_collections(&join.right, &mut collections);
171            "select"
172        }
173        QueryExpr::Insert(insert) => {
174            push_query_audit_collection(&mut collections, &insert.table);
175            "insert"
176        }
177        QueryExpr::Update(update) => {
178            push_query_audit_collection(&mut collections, &update.table);
179            "update"
180        }
181        QueryExpr::Delete(delete) => {
182            push_query_audit_collection(&mut collections, &delete.table);
183            "delete"
184        }
185        _ => return None,
186    };
187    if collections.is_empty() {
188        None
189    } else {
190        Some(QueryAuditPlan {
191            statement_kind,
192            collections,
193        })
194    }
195}
196
197fn collect_query_audit_collections(expr: &QueryExpr, collections: &mut Vec<String>) {
198    match expr {
199        QueryExpr::Table(table) => push_query_audit_collection(collections, &table.table),
200        QueryExpr::Join(join) => {
201            collect_query_audit_collections(&join.left, collections);
202            collect_query_audit_collections(&join.right, collections);
203        }
204        _ => {}
205    }
206}
207
208fn push_query_audit_collection(collections: &mut Vec<String>, name: &str) {
209    if name == "red" || name.starts_with("red.") || name.starts_with("__red_schema_") {
210        return;
211    }
212    if !collections.iter().any(|existing| existing == name) {
213        collections.push(name.to_string());
214    }
215}
216
217const RUNTIME_INDEX_REGISTRY_COLLECTION: &str = "red_index_registry";
218
219impl RedDBRuntime {
220    fn execute_create_metric(
221        &self,
222        raw_query: &str,
223        query: &crate::storage::query::ast::CreateMetricQuery,
224    ) -> RedDBResult<RuntimeQueryResult> {
225        self.check_write(crate::runtime::write_gate::WriteKind::Ddl)?;
226        let store = self.inner.db.store();
227        super::metric_descriptor_catalog::create(
228            store.as_ref(),
229            &query.path,
230            &query.kind,
231            &query.role,
232            super::metric_descriptor_catalog::DerivedSpec {
233                source: query.source.clone(),
234                query: query.query.clone(),
235                window_ms: query.window_ms,
236                time_field: query.time_field.clone(),
237            },
238        )?;
239        self.invalidate_result_cache();
240        Ok(RuntimeQueryResult::ok_message(
241            raw_query.to_string(),
242            &format!("metric descriptor '{}' created", query.path),
243            "create",
244        ))
245    }
246
247    /// `CREATE RANKING <name> ON <table> (<column> [ASC|DESC]) [TOP <k>]`
248    /// — declare a Ranking capability over an ordinary table's score
249    /// column (issue #918 / ADR 0035). Persists a WAL-backed catalog
250    /// record; no new Collection model is introduced. Authorized through
251    /// the same DDL write gate as `CREATE METRIC`/`CREATE INDEX`.
252    fn execute_create_ranking(
253        &self,
254        raw_query: &str,
255        req: super::ranking_descriptor_catalog::CreateRankingRequest,
256    ) -> RedDBResult<RuntimeQueryResult> {
257        self.check_write(crate::runtime::write_gate::WriteKind::Ddl)?;
258        let store = self.inner.db.store();
259        let descriptor = super::ranking_descriptor_catalog::create(store.as_ref(), &req)?;
260        self.invalidate_result_cache();
261        Ok(RuntimeQueryResult::ok_message(
262            raw_query.to_string(),
263            &format!(
264                "ranking '{}' created on {}({})",
265                descriptor.name, descriptor.table, descriptor.column
266            ),
267            "create",
268        ))
269    }
270
271    /// `SHOW RANKINGS` — project the declared Ranking capabilities back as
272    /// rows, so a declared capability is observable (the Analytics
273    /// "prefer SELECT over admin verbs" rule).
274    fn execute_show_rankings(&self, raw_query: &str) -> RedDBResult<RuntimeQueryResult> {
275        let store = self.inner.db.store();
276        let entries = super::ranking_descriptor_catalog::list(store.as_ref());
277        let columns = vec![
278            "name".to_string(),
279            "table".to_string(),
280            "column".to_string(),
281            "direction".to_string(),
282            "top_k".to_string(),
283        ];
284        let rows = entries
285            .into_iter()
286            .map(|e| {
287                vec![
288                    ("name".to_string(), Value::text(e.name)),
289                    ("table".to_string(), Value::text(e.table)),
290                    ("column".to_string(), Value::text(e.column)),
291                    (
292                        "direction".to_string(),
293                        Value::text(if e.descending { "DESC" } else { "ASC" }.to_string()),
294                    ),
295                    ("top_k".to_string(), Value::UnsignedInteger(e.top_k)),
296                ]
297            })
298            .collect();
299        let mut result =
300            RuntimeQueryResult::ok_records(raw_query.to_string(), columns, rows, "select");
301        result.statement = "rank_of";
302        result.engine = "runtime-rank";
303        Ok(result)
304    }
305
306    /// `RANK OF <id> IN <name>` — exact, MVCC-correct rank of a specific
307    /// row within the capability's bounded top-K head (issue #918).
308    ///
309    /// Returns a single `rank` row when the row is visible *and* falls
310    /// inside the exact head; an empty result otherwise (not visible, or
311    /// in the approximate tail — a separate slice). The computation runs
312    /// entirely over the regular read pipeline so it inherits MVCC
313    /// visibility, RLS/policy, and tenant scope from ordinary reads.
314    fn execute_rank_of(
315        &self,
316        raw_query: &str,
317        req: &crate::storage::query::ast::RankOfQuery,
318    ) -> RedDBResult<RuntimeQueryResult> {
319        let store = self.inner.db.store();
320        let descriptor = super::ranking_descriptor_catalog::get(store.as_ref(), &req.ranking)
321            .ok_or_else(|| {
322                RedDBError::Query(format!("ranking '{}' does not exist", req.ranking))
323            })?;
324        let rank = self.compute_exact_head_rank(&descriptor, req.entity_id)?;
325        let columns = vec!["rank".to_string()];
326        let rows = match rank {
327            Some(rank) => vec![vec![("rank".to_string(), Value::UnsignedInteger(rank))]],
328            None => Vec::new(),
329        };
330        let mut result =
331            RuntimeQueryResult::ok_records(raw_query.to_string(), columns, rows, "select");
332        result.statement = "rank_range";
333        result.engine = "runtime-rank";
334        Ok(result)
335    }
336
337    /// `RANK RANGE <lo> TO <hi> IN <name>` — exact, MVCC-correct entries
338    /// occupying a contiguous rank range within the bounded top-K head.
339    ///
340    /// The output is in leaderboard order and includes `rank` plus the
341    /// row columns returned by the canonical exact-head SQL read.
342    fn execute_rank_range(
343        &self,
344        raw_query: &str,
345        req: &crate::storage::query::ast::RankRangeQuery,
346    ) -> RedDBResult<RuntimeQueryResult> {
347        let store = self.inner.db.store();
348        let descriptor = super::ranking_descriptor_catalog::get(store.as_ref(), &req.ranking)
349            .ok_or_else(|| {
350                RedDBError::Query(format!("ranking '{}' does not exist", req.ranking))
351            })?;
352        let (head_columns, entries) = self.compute_ranked_head_entries(&descriptor)?;
353
354        let mut columns = Vec::with_capacity(head_columns.len() + 1);
355        columns.push("rank".to_string());
356        for column in &head_columns {
357            if column != "rank" {
358                columns.push(column.clone());
359            }
360        }
361
362        let rows = entries
363            .into_iter()
364            .filter(|entry| entry.rank >= req.lo && entry.rank <= req.hi)
365            .map(|entry| {
366                let mut row = Vec::with_capacity(columns.len());
367                row.push(("rank".to_string(), Value::UnsignedInteger(entry.rank)));
368                for column in &head_columns {
369                    if column == "rank" {
370                        continue;
371                    }
372                    if let Some(value) = entry.record.get(column) {
373                        row.push((column.clone(), value.clone()));
374                    }
375                }
376                row
377            })
378            .collect();
379        let mut result =
380            RuntimeQueryResult::ok_records(raw_query.to_string(), columns, rows, "select");
381        result.statement = "approx_rank_of";
382        result.engine = "runtime-rank";
383        Ok(result)
384    }
385
386    /// Compute the exact rank of `target_id` within the descriptor's
387    /// bounded top-K head, or `None` if the row is invisible to the
388    /// querying snapshot or beyond the exact head.
389    ///
390    /// Faithful to ADR 0035: it walks the sorted index head
391    /// (`ORDER BY <col> {DESC|ASC} LIMIT k`, served by
392    /// `try_sorted_index_lookup` + the per-row MVCC visibility re-check)
393    /// and counts only rows visible to the current snapshot. Running the
394    /// head scan through `execute_query_inner` keeps it on the same
395    /// snapshot/tenant/policy frame as ordinary reads, so the rank agrees
396    /// with `ORDER BY <col> {DESC|ASC} LIMIT` under that snapshot by
397    /// construction. RANK semantics: tied scores share a rank, so the
398    /// rank is `1 + (number of strictly-better visible rows)`.
399    fn compute_exact_head_rank(
400        &self,
401        descriptor: &super::ranking_descriptor_catalog::RankingDescriptor,
402        target_id: u64,
403    ) -> RedDBResult<Option<u64>> {
404        let (_columns, entries) = self.compute_ranked_head_entries(descriptor)?;
405        Ok(entries
406            .into_iter()
407            .find(|entry| record_rid_u64(&entry.record) == Some(target_id))
408            .map(|entry| entry.rank))
409    }
410
411    /// Return the exact head rows in deterministic rank order.
412    fn compute_ranked_head_entries(
413        &self,
414        descriptor: &super::ranking_descriptor_catalog::RankingDescriptor,
415    ) -> RedDBResult<(Vec<String>, Vec<RankedHeadEntry>)> {
416        let table = &descriptor.table;
417        let column = &descriptor.column;
418
419        // The exact head: top-K rows in rank order. Each row here already
420        // passed MVCC visibility *and* RLS/tenant filtering during the
421        // scan, so identifying the target *within* this result (rather
422        // than via a separate `rid` lookup, which takes the
423        // direct entity-fetch path that bypasses the RLS gate) is what
424        // makes the rank honor policy/tenant scope (criterion 5).
425        let dir = if descriptor.descending { "DESC" } else { "ASC" };
426        let head_sql = format!(
427            "SELECT * FROM {table} ORDER BY {column} {dir}, rid ASC LIMIT {}",
428            descriptor.top_k
429        );
430        let head_result = self.execute_query_inner(&head_sql)?;
431
432        let mut entries = Vec::with_capacity(head_result.result.records.len());
433        let mut row_position = 0u64;
434        let mut current_rank = 0u64;
435        let mut previous_score: Option<f64> = None;
436        for rec in &head_result.result.records {
437            let Some(score) = record_column_f64(rec, column) else {
438                continue;
439            };
440            row_position += 1;
441            current_rank = if previous_score == Some(score) {
442                current_rank
443            } else {
444                row_position
445            };
446            previous_score = Some(score);
447            entries.push(RankedHeadEntry {
448                rank: current_rank,
449                record: rec.clone(),
450            });
451        }
452        Ok((head_result.result.columns, entries))
453    }
454
455    /// `APPROX RANK OF <id> IN <name>` — the *approximate tail* read
456    /// (issue #923 / ADR 0035). Serves an explicitly-approximate
457    /// percentile / rank for an entry below the exact top-K head from a
458    /// per-`(table, column)` score sketch.
459    ///
460    /// The result is always labeled approximate (`approximate = true`,
461    /// distinct from the exact `RANK OF` surface which returns only a bare
462    /// `rank`) so a caller never reads a tail estimate as an exact head
463    /// position. An invisible / non-existent row yields no row, exactly
464    /// like the exact surface.
465    fn execute_approx_rank_of(
466        &self,
467        raw_query: &str,
468        req: &crate::storage::query::ast::RankOfQuery,
469    ) -> RedDBResult<RuntimeQueryResult> {
470        let store = self.inner.db.store();
471        let descriptor = super::ranking_descriptor_catalog::get(store.as_ref(), &req.ranking)
472            .ok_or_else(|| {
473                RedDBError::Query(format!("ranking '{}' does not exist", req.ranking))
474            })?;
475
476        let approx = self.compute_approx_rank(&descriptor, req.entity_id)?;
477        let columns = vec![
478            "rank".to_string(),
479            "percentile".to_string(),
480            "approximate".to_string(),
481        ];
482        let rows = match approx {
483            Some(approx) => vec![vec![
484                ("rank".to_string(), Value::UnsignedInteger(approx.rank)),
485                ("percentile".to_string(), Value::Float(approx.percentile)),
486                ("approximate".to_string(), Value::Boolean(true)),
487            ]],
488            None => Vec::new(),
489        };
490        let mut result =
491            RuntimeQueryResult::ok_records(raw_query.to_string(), columns, rows, "select");
492        result.statement = "approx_rank_of";
493        // Tag as `runtime-rank` so the 30s result cache skips this read
494        // (see `should_write_result_cache`). The approximate rank is rebuilt
495        // from a live full scan on every call (criterion 4: it must track
496        // score changes); a cached entry, scoped only to the ranking name and
497        // never the underlying table, would otherwise survive inserts into
498        // that table and serve a stale rank.
499        result.engine = "runtime-rank";
500        Ok(result)
501    }
502
503    /// Refresh the per-`(table, column)` score sketch from the rows visible
504    /// to the current snapshot and return the target's approximate rank, or
505    /// `None` if the target row is invisible to this snapshot / tenant.
506    ///
507    /// The sketch is rebuilt from the live column on each read and persisted
508    /// back to `red_config` keyed by `(table, column)` — so it is maintained
509    /// per `(collection, score column)` and stays current as scores change
510    /// (criterion 4). The scan runs through `execute_query_inner`, inheriting
511    /// the same MVCC snapshot, RLS/tenant scope, and policy as ordinary
512    /// reads. The *approximation* is the histogram bucketing in
513    /// [`super::score_sketch::ScoreSketch`], not the data freshness, so the
514    /// estimate carries the documented error band even though it is built
515    /// from a full scan in this v0 (incremental maintenance is an ADR-0035
516    /// implementation detail, left open and reversible).
517    fn compute_approx_rank(
518        &self,
519        descriptor: &super::ranking_descriptor_catalog::RankingDescriptor,
520        target_id: u64,
521    ) -> RedDBResult<Option<super::score_sketch::ApproxRank>> {
522        let table = &descriptor.table;
523        let column = &descriptor.column;
524
525        // Scan the visible rows once: it both feeds the sketch and locates
526        // the target's score under the same snapshot/tenant/policy frame.
527        let scan_sql = format!("SELECT * FROM {table}");
528        let scan = self.execute_query_inner(&scan_sql)?;
529        let records = &scan.result.records;
530
531        let mut scores: Vec<f64> = Vec::with_capacity(records.len());
532        let mut target_score: Option<f64> = None;
533        for rec in records {
534            let Some(score) = record_column_f64(rec, column) else {
535                continue;
536            };
537            scores.push(score);
538            let rid = match rec.get("rid") {
539                Some(Value::UnsignedInteger(n)) => Some(*n),
540                Some(Value::Integer(n)) if *n >= 0 => Some(*n as u64),
541                _ => None,
542            };
543            if rid == Some(target_id) {
544                target_score = Some(score);
545            }
546        }
547
548        let sketch = super::score_sketch::ScoreSketch::from_scores(&scores);
549        // Persist the refreshed sketch per (table, column).
550        super::ranking_descriptor_catalog::save_sketch(
551            self.inner.db.store().as_ref(),
552            table,
553            column,
554            &sketch,
555        );
556
557        let Some(target_score) = target_score else {
558            // Not visible to this snapshot/tenant ⇒ no rank (matches exact).
559            return Ok(None);
560        };
561        Ok(sketch.approx_rank(target_score, descriptor.descending))
562    }
563
564    fn execute_alter_metric(
565        &self,
566        raw_query: &str,
567        query: &crate::storage::query::ast::AlterMetricQuery,
568    ) -> RedDBResult<RuntimeQueryResult> {
569        self.check_write(crate::runtime::write_gate::WriteKind::Ddl)?;
570        let store = self.inner.db.store();
571        super::metric_descriptor_catalog::update(
572            store.as_ref(),
573            &query.path,
574            query.set_role.as_deref(),
575            query.attempted_kind.as_deref(),
576            query.attempted_path.as_deref(),
577        )?;
578        self.invalidate_result_cache();
579        Ok(RuntimeQueryResult::ok_message(
580            raw_query.to_string(),
581            &format!("metric descriptor '{}' updated", query.path),
582            "alter",
583        ))
584    }
585
586    fn execute_create_slo(
587        &self,
588        raw_query: &str,
589        query: &crate::storage::query::ast::CreateSloQuery,
590    ) -> RedDBResult<RuntimeQueryResult> {
591        self.check_write(crate::runtime::write_gate::WriteKind::Ddl)?;
592        let store = self.inner.db.store();
593        super::slo_descriptor_catalog::create(
594            store.as_ref(),
595            &query.path,
596            &query.metric_path,
597            query.target,
598            query.window_ms,
599        )?;
600        self.invalidate_result_cache();
601        Ok(RuntimeQueryResult::ok_message(
602            raw_query.to_string(),
603            &format!("SLO descriptor '{}' created", query.path),
604            "create",
605        ))
606    }
607
608    fn execute_create_analytics_source(
609        &self,
610        raw_query: &str,
611        query: super::analytics_source_catalog::CreateAnalyticsSourceProfile,
612    ) -> RedDBResult<RuntimeQueryResult> {
613        self.check_write(crate::runtime::write_gate::WriteKind::Ddl)?;
614        let store = self.inner.db.store();
615        let profile = super::analytics_source_catalog::create(
616            store.as_ref(),
617            &self.inner.db.collection_contracts(),
618            query,
619        )?;
620        self.invalidate_result_cache();
621        Ok(RuntimeQueryResult::ok_message(
622            raw_query.to_string(),
623            &format!("analytics source '{}' created", profile.name),
624            "create",
625        ))
626    }
627}
628
629fn query_control_event_specs(expr: &QueryExpr) -> Vec<QueryControlEventSpec> {
630    use crate::runtime::control_events::{EventKind, Sensitivity};
631
632    let mut specs = Vec::new();
633    let mut schema = |action: &'static str, resource: Option<String>| {
634        specs.push(QueryControlEventSpec {
635            kind: EventKind::SchemaDdl,
636            action,
637            resource,
638            fields: Vec::new(),
639        });
640    };
641    match expr {
642        QueryExpr::CreateTable(q) => {
643            schema("create_table", Some(format!("table:{}", q.name)));
644            if let Some(column) = &q.tenant_by {
645                specs.push(QueryControlEventSpec {
646                    kind: EventKind::TenantGovernance,
647                    action: "create_table_tenant_by",
648                    resource: Some(format!("table:{}", q.name)),
649                    fields: vec![("tenant_column".to_string(), Sensitivity::raw(column))],
650                });
651            }
652        }
653        QueryExpr::CreateCollection(q) => {
654            schema("create_collection", Some(format!("collection:{}", q.name)));
655        }
656        QueryExpr::CreateVector(q) => schema("create_vector", Some(format!("vector:{}", q.name))),
657        QueryExpr::DropTable(q) => schema("drop_table", Some(format!("table:{}", q.name))),
658        QueryExpr::DropGraph(q) => schema("drop_graph", Some(format!("graph:{}", q.name))),
659        QueryExpr::DropVector(q) => schema("drop_vector", Some(format!("vector:{}", q.name))),
660        QueryExpr::DropDocument(q) => {
661            schema("drop_document", Some(format!("document:{}", q.name)));
662        }
663        QueryExpr::DropKv(q) => schema("drop_kv", Some(format!("kv:{}", q.name))),
664        QueryExpr::DropCollection(q) => {
665            schema("drop_collection", Some(format!("collection:{}", q.name)));
666        }
667        QueryExpr::Truncate(q) => schema("truncate", Some(format!("collection:{}", q.name))),
668        QueryExpr::AlterTable(q) => {
669            schema("alter_table", Some(format!("table:{}", q.name)));
670            for op in &q.operations {
671                match op {
672                    crate::storage::query::ast::AlterOperation::EnableRowLevelSecurity => {
673                        specs.push(QueryControlEventSpec {
674                            kind: EventKind::RlsGovernance,
675                            action: "enable_rls",
676                            resource: Some(format!("table:{}", q.name)),
677                            fields: Vec::new(),
678                        });
679                    }
680                    crate::storage::query::ast::AlterOperation::DisableRowLevelSecurity => {
681                        specs.push(QueryControlEventSpec {
682                            kind: EventKind::RlsGovernance,
683                            action: "disable_rls",
684                            resource: Some(format!("table:{}", q.name)),
685                            fields: Vec::new(),
686                        });
687                    }
688                    crate::storage::query::ast::AlterOperation::EnableTenancy { column } => {
689                        specs.push(QueryControlEventSpec {
690                            kind: EventKind::TenantGovernance,
691                            action: "enable_tenancy",
692                            resource: Some(format!("table:{}", q.name)),
693                            fields: vec![("tenant_column".to_string(), Sensitivity::raw(column))],
694                        });
695                    }
696                    crate::storage::query::ast::AlterOperation::DisableTenancy => {
697                        specs.push(QueryControlEventSpec {
698                            kind: EventKind::TenantGovernance,
699                            action: "disable_tenancy",
700                            resource: Some(format!("table:{}", q.name)),
701                            fields: Vec::new(),
702                        });
703                    }
704                    _ => {}
705                }
706            }
707        }
708        QueryExpr::CreateIndex(q) => {
709            schema(
710                "create_index",
711                Some(format!("index:{}:{}", q.table, q.name)),
712            );
713        }
714        QueryExpr::DropIndex(q) => {
715            schema("drop_index", Some(format!("index:{}:{}", q.table, q.name)));
716        }
717        QueryExpr::CreateTimeSeries(q) => {
718            schema("create_timeseries", Some(format!("timeseries:{}", q.name)));
719        }
720        QueryExpr::CreateMetric(q) => {
721            schema("create_metric", Some(format!("metric:{}", q.path)));
722        }
723        QueryExpr::AlterMetric(q) => {
724            schema("alter_metric", Some(format!("metric:{}", q.path)));
725        }
726        QueryExpr::CreateSlo(q) => {
727            schema("create_slo", Some(format!("slo:{}", q.path)));
728        }
729        QueryExpr::DropTimeSeries(q) => {
730            schema("drop_timeseries", Some(format!("timeseries:{}", q.name)));
731        }
732        QueryExpr::CreateQueue(q) => schema("create_queue", Some(format!("queue:{}", q.name))),
733        QueryExpr::AlterQueue(q) => schema("alter_queue", Some(format!("queue:{}", q.name))),
734        QueryExpr::DropQueue(q) => schema("drop_queue", Some(format!("queue:{}", q.name))),
735        QueryExpr::CreateTree(q) => {
736            schema(
737                "create_tree",
738                Some(format!("tree:{}:{}", q.collection, q.name)),
739            );
740        }
741        QueryExpr::DropTree(q) => {
742            schema(
743                "drop_tree",
744                Some(format!("tree:{}:{}", q.collection, q.name)),
745            );
746        }
747        QueryExpr::CreateSchema(q) => schema("create_schema", Some(format!("schema:{}", q.name))),
748        QueryExpr::DropSchema(q) => schema("drop_schema", Some(format!("schema:{}", q.name))),
749        QueryExpr::CreateSequence(q) => {
750            schema("create_sequence", Some(format!("sequence:{}", q.name)));
751        }
752        QueryExpr::DropSequence(q) => schema("drop_sequence", Some(format!("sequence:{}", q.name))),
753        QueryExpr::CreateView(q) => schema("create_view", Some(format!("view:{}", q.name))),
754        QueryExpr::DropView(q) => schema("drop_view", Some(format!("view:{}", q.name))),
755        QueryExpr::RefreshMaterializedView(q) => {
756            schema(
757                "refresh_materialized_view",
758                Some(format!("view:{}", q.name)),
759            );
760        }
761        QueryExpr::CreatePolicy(q) => {
762            specs.push(QueryControlEventSpec {
763                kind: EventKind::RlsGovernance,
764                action: "create_policy",
765                resource: Some(format!("table:{}:policy:{}", q.table, q.name)),
766                fields: vec![(
767                    "target_kind".to_string(),
768                    Sensitivity::raw(q.target_kind.as_ident()),
769                )],
770            });
771        }
772        QueryExpr::DropPolicy(q) => {
773            specs.push(QueryControlEventSpec {
774                kind: EventKind::RlsGovernance,
775                action: "drop_policy",
776                resource: Some(format!("table:{}:policy:{}", q.table, q.name)),
777                fields: Vec::new(),
778            });
779        }
780        QueryExpr::SetTenant(value) => {
781            let mut fields = Vec::new();
782            if let Some(value) = value {
783                fields.push(("tenant".to_string(), Sensitivity::raw(value)));
784            }
785            specs.push(QueryControlEventSpec {
786                kind: EventKind::TenantGovernance,
787                action: "set_tenant",
788                resource: Some("tenant:session".to_string()),
789                fields,
790            });
791        }
792        QueryExpr::SetConfig { key, .. } => {
793            specs.push(QueryControlEventSpec {
794                kind: EventKind::ConfigWrite,
795                action: "config:write",
796                resource: Some(format!("config:{key}")),
797                fields: vec![("key".to_string(), Sensitivity::raw(key))],
798            });
799        }
800        QueryExpr::ConfigCommand(cmd) => match cmd {
801            crate::storage::query::ast::ConfigCommand::Put {
802                collection, key, ..
803            }
804            | crate::storage::query::ast::ConfigCommand::Rotate {
805                collection, key, ..
806            } => {
807                let target = format!("{collection}/{key}");
808                specs.push(QueryControlEventSpec {
809                    kind: EventKind::ConfigWrite,
810                    action: "config:write",
811                    resource: Some(format!("config:{target}")),
812                    fields: vec![
813                        ("collection".to_string(), Sensitivity::raw(collection)),
814                        ("key".to_string(), Sensitivity::raw(key)),
815                    ],
816                });
817            }
818            crate::storage::query::ast::ConfigCommand::Delete { collection, key } => {
819                let target = format!("{collection}/{key}");
820                specs.push(QueryControlEventSpec {
821                    kind: EventKind::ConfigDelete,
822                    action: "config:write",
823                    resource: Some(format!("config:{target}")),
824                    fields: vec![
825                        ("collection".to_string(), Sensitivity::raw(collection)),
826                        ("key".to_string(), Sensitivity::raw(key)),
827                    ],
828                });
829            }
830            _ => {}
831        },
832        QueryExpr::AlterUser(stmt) => {
833            let disables = stmt.attributes.iter().any(|attr| {
834                matches!(
835                    attr,
836                    crate::storage::query::ast::AlterUserAttribute::Disable
837                )
838            });
839            specs.push(QueryControlEventSpec {
840                kind: if disables {
841                    EventKind::UserDisable
842                } else {
843                    EventKind::UserUpdate
844                },
845                action: "alter_user",
846                resource: Some(format!("user:{}", stmt.username)),
847                fields: Vec::new(),
848            });
849        }
850        QueryExpr::CreateUser(stmt) => {
851            specs.push(QueryControlEventSpec {
852                kind: EventKind::UserCreate,
853                action: "create_user",
854                resource: Some(format!("user:{}", stmt.username)),
855                fields: Vec::new(),
856            });
857        }
858        _ => {}
859    }
860    specs
861}
862
863pub(crate) fn control_event_outcome_for_error(
864    err: &RedDBError,
865) -> crate::runtime::control_events::Outcome {
866    match err {
867        RedDBError::ReadOnly(_) => crate::runtime::control_events::Outcome::Denied,
868        RedDBError::Query(msg)
869            if msg.contains("permission denied")
870                || msg.contains("cannot issue")
871                || msg.contains("lacks") =>
872        {
873            crate::runtime::control_events::Outcome::Denied
874        }
875        _ => crate::runtime::control_events::Outcome::Error,
876    }
877}
878
879/// Convert the rows produced by a materialized-view body into
880/// `UnifiedEntity` table rows targeting the backing collection.
881/// Issue #595 slice 9c — feeds `UnifiedStore::refresh_collection`.
882///
883/// Graph fragments and vector hits are ignored: a materialized view
884/// is a relational result set (SELECT-shaped); slices 11+ may extend
885/// this once we have a richer view body shape. Each row materialises
886/// the union of its schema-bound columns + overflow.
887fn view_records_to_entities(
888    table: &str,
889    records: &[crate::storage::query::unified::UnifiedRecord],
890) -> Vec<crate::storage::UnifiedEntity> {
891    use std::collections::HashMap;
892    let table_arc: std::sync::Arc<str> = std::sync::Arc::from(table);
893    let mut out = Vec::with_capacity(records.len());
894    for record in records {
895        let mut named: HashMap<String, crate::storage::schema::Value> = HashMap::new();
896        for (name, value) in record.iter_fields() {
897            named.insert(name.to_string(), value.clone());
898        }
899        let entity = crate::storage::UnifiedEntity::new(
900            crate::storage::EntityId::new(0),
901            crate::storage::EntityKind::TableRow {
902                table: std::sync::Arc::clone(&table_arc),
903                row_id: 0,
904            },
905            crate::storage::EntityData::Row(crate::storage::RowData {
906                columns: Vec::new(),
907                named: Some(named),
908                schema: None,
909            }),
910        );
911        out.push(entity);
912    }
913    out
914}
915
916fn system_keyed_collection_contract(
917    name: &str,
918    model: crate::catalog::CollectionModel,
919) -> crate::physical::CollectionContract {
920    let now = crate::utils::now_unix_millis() as u128;
921    crate::physical::CollectionContract {
922        name: name.to_string(),
923        declared_model: model,
924        schema_mode: crate::catalog::SchemaMode::Dynamic,
925        origin: crate::physical::ContractOrigin::Implicit,
926        version: 1,
927        created_at_unix_ms: now,
928        updated_at_unix_ms: now,
929        default_ttl_ms: None,
930        vector_dimension: None,
931        vector_metric: None,
932        context_index_fields: Vec::new(),
933        declared_columns: Vec::new(),
934        table_def: None,
935        timestamps_enabled: false,
936        context_index_enabled: false,
937        metrics_raw_retention_ms: None,
938        metrics_rollup_policies: Vec::new(),
939        metrics_tenant_identity: None,
940        metrics_namespace: None,
941        append_only: false,
942        subscriptions: Vec::new(),
943        analytics_config: Vec::new(),
944        session_key: None,
945        session_gap_ms: None,
946        retention_duration_ms: None,
947        analytical_storage: None,
948
949        ai_policy: None,
950    }
951}
952
953pub use super::execution_context::{
954    capture_current_snapshot, clear_current_auth_identity, clear_current_connection_id,
955    clear_current_snapshot, clear_current_tenant, current_auth_identity_for_audit,
956    current_connection_id, current_tenant, entity_visible_under_current_snapshot,
957    entity_visible_with_context, set_current_auth_identity, set_current_connection_id,
958    set_current_snapshot, set_current_tenant, snapshot_bundle, with_snapshot_bundle,
959    SnapshotBundle, SnapshotContext,
960};
961pub(crate) use super::execution_context::{
962    current_auth_identity, current_config_value, current_role_projected, current_scope_override,
963    current_secret_value, current_snapshot_requires_index_fallback, current_user_projected,
964    has_scope_override_active, parse_set_local_tenant, update_current_config_value,
965    update_current_secret_value, xids_visible_under_current_snapshot, ConfigSnapshotGuard,
966    CurrentSnapshotGuard, ScopeOverrideGuard, SecretStoreGuard, TxLocalTenantGuard,
967};
968
969fn table_row_index_fields(
970    entity: &crate::storage::unified::entity::UnifiedEntity,
971) -> Vec<(String, crate::storage::schema::Value)> {
972    let crate::storage::EntityData::Row(row) = &entity.data else {
973        return Vec::new();
974    };
975    if let Some(named) = &row.named {
976        return named
977            .iter()
978            .map(|(name, value)| (name.clone(), value.clone()))
979            .collect();
980    }
981    if let Some(schema) = &row.schema {
982        return schema
983            .iter()
984            .zip(row.columns.iter())
985            .map(|(name, value)| (name.clone(), value.clone()))
986            .collect();
987    }
988    Vec::new()
989}
990
991fn named_text(
992    named: &std::collections::HashMap<String, crate::storage::schema::Value>,
993    key: &str,
994) -> Option<String> {
995    match named.get(key) {
996        Some(crate::storage::schema::Value::Text(value)) => Some(value.to_string()),
997        _ => None,
998    }
999}
1000
1001fn named_bool(
1002    named: &std::collections::HashMap<String, crate::storage::schema::Value>,
1003    key: &str,
1004) -> Option<bool> {
1005    match named.get(key) {
1006        Some(crate::storage::schema::Value::Boolean(value)) => Some(*value),
1007        _ => None,
1008    }
1009}
1010
1011fn index_method_kind_as_str(method: super::index_store::IndexMethodKind) -> &'static str {
1012    match method {
1013        super::index_store::IndexMethodKind::Hash => "hash",
1014        super::index_store::IndexMethodKind::Bitmap => "bitmap",
1015        super::index_store::IndexMethodKind::Spatial => "spatial",
1016        super::index_store::IndexMethodKind::BTree => "btree",
1017    }
1018}
1019
1020fn index_method_kind_from_str(raw: &str) -> Option<super::index_store::IndexMethodKind> {
1021    match raw {
1022        "hash" => Some(super::index_store::IndexMethodKind::Hash),
1023        "bitmap" => Some(super::index_store::IndexMethodKind::Bitmap),
1024        "spatial" | "rtree" => Some(super::index_store::IndexMethodKind::Spatial),
1025        "btree" => Some(super::index_store::IndexMethodKind::BTree),
1026        _ => None,
1027    }
1028}
1029
1030fn runtime_pool_lock(runtime: &RedDBRuntime) -> std::sync::MutexGuard<'_, PoolState> {
1031    runtime
1032        .inner
1033        .pool
1034        .lock()
1035        .unwrap_or_else(|poisoned| poisoned.into_inner())
1036}
1037
1038/// The graph-analytics table-valued functions recognized in FROM position.
1039/// Both the graph-collection form and the inline `nodes => / edges =>` form
1040/// (issue #799) accept these names.
1041fn is_graph_tvf_name(name: &str) -> bool {
1042    name.eq_ignore_ascii_case("components")
1043        || name.eq_ignore_ascii_case("louvain")
1044        || name.eq_ignore_ascii_case("degree_centrality")
1045        || name.eq_ignore_ascii_case("shortest_path")
1046        || name.eq_ignore_ascii_case("betweenness")
1047        || name.eq_ignore_ascii_case("eigenvector")
1048        || name.eq_ignore_ascii_case("pagerank")
1049}
1050
1051/// Map a declared `WITH ANALYTICS` view to the concrete graph algorithm name
1052/// and named-argument list that [`RedDBRuntime::dispatch_graph_algorithm`]
1053/// consumes (issue #800). The `using` option selects the algorithm inside the
1054/// output family; unsupported algorithms and the options that do not apply to
1055/// the chosen algorithm are rejected so a view never silently ignores a
1056/// declared parameter.
1057fn analytics_view_algorithm(
1058    graph: &str,
1059    view: &crate::catalog::AnalyticsViewDescriptor,
1060) -> RedDBResult<(String, Vec<(String, f64)>)> {
1061    use crate::catalog::AnalyticsOutput;
1062
1063    let mut named_args: Vec<(String, f64)> = Vec::new();
1064    let algorithm = match view.output {
1065        AnalyticsOutput::Communities => {
1066            let algo = view.algorithm.as_deref().unwrap_or("louvain");
1067            if !algo.eq_ignore_ascii_case("louvain") {
1068                return Err(RedDBError::Query(format!(
1069                    "analytics output 'communities' on graph '{graph}' has unsupported algorithm '{algo}' (expected louvain)"
1070                )));
1071            }
1072            if let Some(resolution) = view.resolution {
1073                named_args.push(("resolution".to_string(), resolution));
1074            }
1075            "louvain".to_string()
1076        }
1077        AnalyticsOutput::Components => {
1078            if let Some(algo) = view.algorithm.as_deref() {
1079                if !algo.eq_ignore_ascii_case("components")
1080                    && !algo.eq_ignore_ascii_case("connected_components")
1081                {
1082                    return Err(RedDBError::Query(format!(
1083                        "analytics output 'components' on graph '{graph}' has unsupported algorithm '{algo}' (expected connected_components)"
1084                    )));
1085                }
1086            }
1087            "components".to_string()
1088        }
1089        AnalyticsOutput::Centrality => {
1090            let algo = view
1091                .algorithm
1092                .as_deref()
1093                .unwrap_or("pagerank")
1094                .to_ascii_lowercase();
1095            match algo.as_str() {
1096                "pagerank" => {
1097                    if let Some(max_iterations) = view.max_iterations {
1098                        named_args.push(("max_iterations".to_string(), max_iterations as f64));
1099                    }
1100                }
1101                "eigenvector" => {
1102                    if let Some(max_iterations) = view.max_iterations {
1103                        named_args.push(("max_iterations".to_string(), max_iterations as f64));
1104                    }
1105                    if let Some(tolerance) = view.tolerance {
1106                        named_args.push(("tolerance".to_string(), tolerance));
1107                    }
1108                }
1109                "betweenness" => {}
1110                other => {
1111                    return Err(RedDBError::Query(format!(
1112                        "analytics output 'centrality' on graph '{graph}' has unsupported algorithm '{other}' (expected pagerank, betweenness, or eigenvector)"
1113                    )));
1114                }
1115            }
1116            algo
1117        }
1118    };
1119    Ok((algorithm, named_args))
1120}
1121
1122/// Reject any named arguments for a TVF that accepts none.
1123fn reject_named_args(name: &str, named_args: &[(String, f64)]) -> RedDBResult<()> {
1124    if let Some((key, _)) = named_args.first() {
1125        return Err(RedDBError::Query(format!(
1126            "table function '{name}' has no named argument '{key}'"
1127        )));
1128    }
1129    Ok(())
1130}
1131
1132/// Resolve louvain's optional `resolution` named arg (γ, default 1.0). Any
1133/// other named key, or a non-finite / non-positive resolution, is rejected.
1134fn louvain_resolution(named_args: &[(String, f64)]) -> RedDBResult<f64> {
1135    let mut resolution = 1.0_f64;
1136    for (key, value) in named_args {
1137        if key.eq_ignore_ascii_case("resolution") {
1138            if !value.is_finite() || *value <= 0.0 {
1139                return Err(RedDBError::Query(format!(
1140                    "table function 'louvain' resolution must be > 0, got {value}"
1141                )));
1142            }
1143            resolution = *value;
1144        } else {
1145            return Err(RedDBError::Query(format!(
1146                "table function 'louvain' has no named argument '{key}' (expected 'resolution')"
1147            )));
1148        }
1149    }
1150    Ok(resolution)
1151}
1152
1153/// Undirected degree centrality over abstract inputs: each edge contributes
1154/// 1 to both of its endpoints. Returns `(node_id, degree)` deterministically
1155/// in ascending node-id order, so identical input always yields identical
1156/// rows.
1157fn abstract_degree_centrality(
1158    nodes: &[String],
1159    edges: &[(
1160        String,
1161        String,
1162        crate::storage::engine::graph_algorithms::Weight,
1163    )],
1164) -> Vec<(String, usize)> {
1165    let mut degree: std::collections::BTreeMap<String, usize> = std::collections::BTreeMap::new();
1166    for n in nodes {
1167        degree.entry(n.clone()).or_insert(0);
1168    }
1169    for (a, b, _w) in edges {
1170        *degree.entry(a.clone()).or_insert(0) += 1;
1171        *degree.entry(b.clone()).or_insert(0) += 1;
1172    }
1173    degree.into_iter().collect()
1174}
1175
1176/// Ordered column names for a materialized subquery result: the projection
1177/// columns when present, else the first record's field order.
1178fn ordered_result_columns(result: &crate::storage::query::unified::UnifiedResult) -> Vec<String> {
1179    if !result.columns.is_empty() {
1180        return result.columns.clone();
1181    }
1182    result
1183        .records
1184        .first()
1185        .map(|record| {
1186            record
1187                .column_names()
1188                .iter()
1189                .map(|column| column.to_string())
1190                .collect()
1191        })
1192        .unwrap_or_default()
1193}
1194
1195/// Canonical node-id string for a cell value, so the node universe (from the
1196/// `nodes` subquery) and the edge endpoints (from the `edges` subquery)
1197/// compare equal regardless of integer-vs-text typing. `Null` is not a node.
1198fn value_to_node_id(value: &crate::storage::schema::Value) -> Option<String> {
1199    use crate::storage::schema::Value;
1200    match value {
1201        Value::Null => None,
1202        Value::Text(s) => Some(s.to_string()),
1203        Value::Integer(n) => Some(n.to_string()),
1204        Value::UnsignedInteger(n) => Some(n.to_string()),
1205        Value::NodeRef(s) => Some(s.clone()),
1206        other => Some(other.to_string()),
1207    }
1208}
1209
1210/// Numeric edge weight from a cell value (the optional third `edges` column).
1211fn value_to_weight(value: &crate::storage::schema::Value) -> Option<f32> {
1212    use crate::storage::schema::Value;
1213    match value {
1214        Value::Float(f) => Some(*f as f32),
1215        Value::Integer(n) => Some(*n as f32),
1216        Value::UnsignedInteger(n) => Some(*n as f32),
1217        _ => None,
1218    }
1219}
1220
1221/// Build the node universe from a materialized `nodes` subquery result: the
1222/// first projected column of each row is the node id (issue #799). Zero rows
1223/// is a valid empty node set; a row set with no columns is a shape error.
1224fn inline_node_ids(
1225    name: &str,
1226    result: &crate::storage::query::unified::UnifiedResult,
1227) -> RedDBResult<Vec<String>> {
1228    if result.records.is_empty() {
1229        return Ok(Vec::new());
1230    }
1231    let columns = ordered_result_columns(result);
1232    let Some(first_col) = columns.first() else {
1233        return Err(RedDBError::Query(format!(
1234            "table function '{name}' inline form: `nodes` subquery must project at least one column (the node id)"
1235        )));
1236    };
1237    let mut ids = Vec::with_capacity(result.records.len());
1238    for record in &result.records {
1239        if let Some(id) = record.get(first_col).and_then(value_to_node_id) {
1240            ids.push(id);
1241        }
1242    }
1243    Ok(ids)
1244}
1245
1246/// Build the edge list from a materialized `edges` subquery result: the first
1247/// two projected columns are `(source, target)` and an optional third column
1248/// is the numeric weight (defaulting to 1.0). Fewer than two columns is a
1249/// shape error (issue #799).
1250fn inline_edges(
1251    name: &str,
1252    result: &crate::storage::query::unified::UnifiedResult,
1253) -> RedDBResult<
1254    Vec<(
1255        String,
1256        String,
1257        crate::storage::engine::graph_algorithms::Weight,
1258    )>,
1259> {
1260    if result.records.is_empty() {
1261        return Ok(Vec::new());
1262    }
1263    let columns = ordered_result_columns(result);
1264    if columns.len() < 2 {
1265        return Err(RedDBError::Query(format!(
1266            "table function '{name}' inline form: `edges` subquery must project at least two columns (source, target), got {}",
1267            columns.len()
1268        )));
1269    }
1270    let src_col = &columns[0];
1271    let dst_col = &columns[1];
1272    let weight_col = columns.get(2);
1273    let mut edges = Vec::with_capacity(result.records.len());
1274    for record in &result.records {
1275        let (Some(src), Some(dst)) = (
1276            record.get(src_col).and_then(value_to_node_id),
1277            record.get(dst_col).and_then(value_to_node_id),
1278        ) else {
1279            // A null/absent endpoint is not a valid edge; skip it.
1280            continue;
1281        };
1282        let weight = match weight_col {
1283            Some(col) => match record.get(col) {
1284                None | Some(crate::storage::schema::Value::Null) => 1.0,
1285                Some(value) => value_to_weight(value).ok_or_else(|| {
1286                    RedDBError::Query(format!(
1287                        "table function '{name}' inline form: `edges` weight column must be numeric"
1288                    ))
1289                })?,
1290            },
1291            None => 1.0,
1292        };
1293        edges.push((src, dst, weight));
1294    }
1295    Ok(edges)
1296}
1297
1298fn cache_scope_insert(scopes: &mut HashSet<String>, name: &str) {
1299    if name.is_empty() || name.starts_with("__subq_") || is_universal_query_source(name) {
1300        return;
1301    }
1302    scopes.insert(name.to_string());
1303}
1304
1305fn collect_table_source_scopes(scopes: &mut HashSet<String>, query: &TableQuery) {
1306    match query.source.as_ref() {
1307        Some(crate::storage::query::ast::TableSource::Name(name)) => {
1308            cache_scope_insert(scopes, name)
1309        }
1310        Some(crate::storage::query::ast::TableSource::Subquery(subquery)) => {
1311            collect_query_expr_result_cache_scopes(scopes, subquery);
1312        }
1313        // Graph-collection TVFs (e.g. `louvain(g)`) read the graph store
1314        // read-only. The result is now cached (issue #802) and scoped to the
1315        // graph collection named in the first argument, so any mutation on
1316        // that collection (`INSERT INTO g NODE/EDGE …`) invalidates the
1317        // entry via `invalidate_result_cache_for_table`. Non-graph or
1318        // zero-arg functions contribute no scope.
1319        Some(crate::storage::query::ast::TableSource::Function { name, args, .. }) => {
1320            if is_graph_tvf_name(name) {
1321                if let Some(graph) = args.first() {
1322                    cache_scope_insert(scopes, graph);
1323                }
1324            }
1325        }
1326        // The inline-graph form reads ordinary tables/docs through its
1327        // `nodes`/`edges` subqueries, so its result cache must be scoped to
1328        // those source collections — mutating any of them invalidates the
1329        // cached result (issue #799).
1330        Some(crate::storage::query::ast::TableSource::InlineGraphFunction {
1331            nodes, edges, ..
1332        }) => {
1333            collect_query_expr_result_cache_scopes(scopes, nodes);
1334            collect_query_expr_result_cache_scopes(scopes, edges);
1335        }
1336        None => cache_scope_insert(scopes, &query.table),
1337    }
1338}
1339
1340fn collect_vector_source_scopes(
1341    scopes: &mut HashSet<String>,
1342    source: &crate::storage::query::ast::VectorSource,
1343) {
1344    match source {
1345        crate::storage::query::ast::VectorSource::Reference { collection, .. } => {
1346            cache_scope_insert(scopes, collection);
1347        }
1348        crate::storage::query::ast::VectorSource::Subquery(subquery) => {
1349            collect_query_expr_result_cache_scopes(scopes, subquery);
1350        }
1351        crate::storage::query::ast::VectorSource::Literal(_)
1352        | crate::storage::query::ast::VectorSource::Text(_) => {}
1353    }
1354}
1355
1356fn collect_path_selector_scopes(
1357    scopes: &mut HashSet<String>,
1358    selector: &crate::storage::query::ast::NodeSelector,
1359) {
1360    if let crate::storage::query::ast::NodeSelector::ByRow { table, .. } = selector {
1361        cache_scope_insert(scopes, table);
1362    }
1363}
1364
1365fn collect_query_expr_result_cache_scopes(scopes: &mut HashSet<String>, expr: &QueryExpr) {
1366    match expr {
1367        QueryExpr::Table(query) => collect_table_source_scopes(scopes, query),
1368        QueryExpr::Join(query) => {
1369            collect_query_expr_result_cache_scopes(scopes, &query.left);
1370            collect_query_expr_result_cache_scopes(scopes, &query.right);
1371        }
1372        QueryExpr::Path(query) => {
1373            collect_path_selector_scopes(scopes, &query.from);
1374            collect_path_selector_scopes(scopes, &query.to);
1375        }
1376        QueryExpr::Vector(query) => {
1377            cache_scope_insert(scopes, &query.collection);
1378            collect_vector_source_scopes(scopes, &query.query_vector);
1379        }
1380        QueryExpr::Hybrid(query) => {
1381            collect_query_expr_result_cache_scopes(scopes, &query.structured);
1382            cache_scope_insert(scopes, &query.vector.collection);
1383            collect_vector_source_scopes(scopes, &query.vector.query_vector);
1384        }
1385        QueryExpr::Insert(query) => cache_scope_insert(scopes, &query.table),
1386        QueryExpr::Update(query) => cache_scope_insert(scopes, &query.table),
1387        QueryExpr::Delete(query) => cache_scope_insert(scopes, &query.table),
1388        QueryExpr::CreateTable(query) => cache_scope_insert(scopes, &query.name),
1389        QueryExpr::CreateCollection(query) => cache_scope_insert(scopes, &query.name),
1390        QueryExpr::CreateVector(query) => cache_scope_insert(scopes, &query.name),
1391        QueryExpr::DropTable(query) => cache_scope_insert(scopes, &query.name),
1392        QueryExpr::DropGraph(query) => cache_scope_insert(scopes, &query.name),
1393        QueryExpr::DropVector(query) => cache_scope_insert(scopes, &query.name),
1394        QueryExpr::DropDocument(query) => cache_scope_insert(scopes, &query.name),
1395        QueryExpr::DropKv(query) => cache_scope_insert(scopes, &query.name),
1396        QueryExpr::DropCollection(query) => cache_scope_insert(scopes, &query.name),
1397        QueryExpr::Truncate(query) => cache_scope_insert(scopes, &query.name),
1398        QueryExpr::AlterTable(query) => cache_scope_insert(scopes, &query.name),
1399        QueryExpr::CreateIndex(query) => cache_scope_insert(scopes, &query.table),
1400        QueryExpr::DropIndex(query) => cache_scope_insert(scopes, &query.table),
1401        QueryExpr::CreateTimeSeries(query) => cache_scope_insert(scopes, &query.name),
1402        QueryExpr::CreateMetric(query) => cache_scope_insert(scopes, &query.path),
1403        QueryExpr::AlterMetric(query) => cache_scope_insert(scopes, &query.path),
1404        QueryExpr::CreateSlo(query) => cache_scope_insert(scopes, &query.path),
1405        QueryExpr::DropTimeSeries(query) => cache_scope_insert(scopes, &query.name),
1406        QueryExpr::CreateQueue(query) => cache_scope_insert(scopes, &query.name),
1407        QueryExpr::AlterQueue(query) => cache_scope_insert(scopes, &query.name),
1408        QueryExpr::DropQueue(query) => cache_scope_insert(scopes, &query.name),
1409        QueryExpr::QueueSelect(query) => cache_scope_insert(scopes, &query.queue),
1410        QueryExpr::QueueCommand(query) => match query {
1411            QueueCommand::Push { queue, .. }
1412            | QueueCommand::Pop { queue, .. }
1413            | QueueCommand::Peek { queue, .. }
1414            | QueueCommand::Len { queue }
1415            | QueueCommand::Purge { queue }
1416            | QueueCommand::GroupCreate { queue, .. }
1417            | QueueCommand::GroupRead { queue, .. }
1418            | QueueCommand::Pending { queue, .. }
1419            | QueueCommand::Claim { queue, .. }
1420            | QueueCommand::Ack { queue, .. }
1421            | QueueCommand::Nack { queue, .. } => cache_scope_insert(scopes, queue),
1422            QueueCommand::Move {
1423                source,
1424                destination,
1425                ..
1426            } => {
1427                cache_scope_insert(scopes, source);
1428                cache_scope_insert(scopes, destination);
1429            }
1430        },
1431        QueryExpr::EventsBackfill(query) => {
1432            cache_scope_insert(scopes, &query.collection);
1433            cache_scope_insert(scopes, &query.target_queue);
1434        }
1435        QueryExpr::CreateTree(query) => cache_scope_insert(scopes, &query.collection),
1436        QueryExpr::DropTree(query) => cache_scope_insert(scopes, &query.collection),
1437        QueryExpr::TreeCommand(query) => match query {
1438            TreeCommand::Insert { collection, .. }
1439            | TreeCommand::Move { collection, .. }
1440            | TreeCommand::Delete { collection, .. }
1441            | TreeCommand::Validate { collection, .. }
1442            | TreeCommand::Rebalance { collection, .. } => cache_scope_insert(scopes, collection),
1443        },
1444        QueryExpr::SearchCommand(query) => match query {
1445            SearchCommand::Similar { collection, .. }
1446            | SearchCommand::Hybrid { collection, .. }
1447            | SearchCommand::SpatialRadius { collection, .. }
1448            | SearchCommand::SpatialBbox { collection, .. }
1449            | SearchCommand::SpatialNearest { collection, .. } => {
1450                cache_scope_insert(scopes, collection);
1451            }
1452            SearchCommand::Text { collection, .. }
1453            | SearchCommand::Multimodal { collection, .. }
1454            | SearchCommand::Index { collection, .. }
1455            | SearchCommand::Context { collection, .. } => {
1456                if let Some(collection) = collection.as_deref() {
1457                    cache_scope_insert(scopes, collection);
1458                }
1459            }
1460        },
1461        QueryExpr::Ask(query) => {
1462            if let Some(collection) = query.collection.as_deref() {
1463                cache_scope_insert(scopes, collection);
1464            }
1465        }
1466        QueryExpr::ExplainAlter(query) => cache_scope_insert(scopes, &query.target.name),
1467        QueryExpr::MaintenanceCommand(cmd) => match cmd {
1468            crate::storage::query::ast::MaintenanceCommand::Vacuum { target, .. }
1469            | crate::storage::query::ast::MaintenanceCommand::Analyze { target } => {
1470                if let Some(t) = target {
1471                    cache_scope_insert(scopes, t);
1472                }
1473            }
1474        },
1475        QueryExpr::CopyFrom(cmd) => cache_scope_insert(scopes, &cmd.table),
1476        QueryExpr::CreateView(cmd) => {
1477            cache_scope_insert(scopes, &cmd.name);
1478            // Invalidating the view should also invalidate its dependencies.
1479            collect_query_expr_result_cache_scopes(scopes, &cmd.query);
1480        }
1481        QueryExpr::DropView(cmd) => cache_scope_insert(scopes, &cmd.name),
1482        QueryExpr::RefreshMaterializedView(cmd) => cache_scope_insert(scopes, &cmd.name),
1483        QueryExpr::CreatePolicy(cmd) => cache_scope_insert(scopes, &cmd.table),
1484        QueryExpr::DropPolicy(cmd) => cache_scope_insert(scopes, &cmd.table),
1485        QueryExpr::CreateServer(_) | QueryExpr::DropServer(_) => {}
1486        QueryExpr::CreateForeignTable(cmd) => cache_scope_insert(scopes, &cmd.name),
1487        QueryExpr::DropForeignTable(cmd) => cache_scope_insert(scopes, &cmd.name),
1488        QueryExpr::Graph(_)
1489        | QueryExpr::GraphCommand(_)
1490        | QueryExpr::ProbabilisticCommand(_)
1491        | QueryExpr::SetConfig { .. }
1492        | QueryExpr::ShowConfig { .. }
1493        | QueryExpr::SetSecret { .. }
1494        | QueryExpr::DeleteSecret { .. }
1495        | QueryExpr::ShowSecrets { .. }
1496        | QueryExpr::SetTenant(_)
1497        | QueryExpr::ShowTenant
1498        | QueryExpr::TransactionControl(_)
1499        | QueryExpr::CreateSchema(_)
1500        | QueryExpr::DropSchema(_)
1501        | QueryExpr::CreateSequence(_)
1502        | QueryExpr::DropSequence(_)
1503        | QueryExpr::Grant(_)
1504        | QueryExpr::Revoke(_)
1505        | QueryExpr::AlterUser(_)
1506        | QueryExpr::CreateUser(_)
1507        | QueryExpr::CreateIamPolicy { .. }
1508        | QueryExpr::DropIamPolicy { .. }
1509        | QueryExpr::AttachPolicy { .. }
1510        | QueryExpr::DetachPolicy { .. }
1511        | QueryExpr::ShowPolicies { .. }
1512        | QueryExpr::ShowEffectivePermissions { .. }
1513        | QueryExpr::RankOf(_)
1514        | QueryExpr::ApproxRankOf(_)
1515        | QueryExpr::RankRange(_)
1516        | QueryExpr::SimulatePolicy { .. }
1517        | QueryExpr::LintPolicy { .. }
1518        | QueryExpr::MigratePolicyMode { .. }
1519        | QueryExpr::CreateMigration(_)
1520        | QueryExpr::ApplyMigration(_)
1521        | QueryExpr::RollbackMigration(_)
1522        | QueryExpr::ExplainMigration(_)
1523        | QueryExpr::EventsBackfillStatus { .. } => {}
1524        QueryExpr::KvCommand(cmd) => {
1525            use crate::storage::query::ast::KvCommand;
1526            match cmd {
1527                KvCommand::Put { collection, .. }
1528                | KvCommand::InvalidateTags { collection, .. }
1529                | KvCommand::Get { collection, .. }
1530                | KvCommand::Unseal { collection, .. }
1531                | KvCommand::Rotate { collection, .. }
1532                | KvCommand::History { collection, .. }
1533                | KvCommand::List { collection, .. }
1534                | KvCommand::Purge { collection, .. }
1535                | KvCommand::Watch { collection, .. }
1536                | KvCommand::Delete { collection, .. }
1537                | KvCommand::Incr { collection, .. }
1538                | KvCommand::Cas { collection, .. } => cache_scope_insert(scopes, collection),
1539            }
1540        }
1541        QueryExpr::ConfigCommand(cmd) => {
1542            use crate::storage::query::ast::ConfigCommand;
1543            match cmd {
1544                ConfigCommand::Put { collection, .. }
1545                | ConfigCommand::Get { collection, .. }
1546                | ConfigCommand::Resolve { collection, .. }
1547                | ConfigCommand::Rotate { collection, .. }
1548                | ConfigCommand::Delete { collection, .. }
1549                | ConfigCommand::History { collection, .. }
1550                | ConfigCommand::List { collection, .. }
1551                | ConfigCommand::Watch { collection, .. }
1552                | ConfigCommand::InvalidVolatileOperation { collection, .. } => {
1553                    cache_scope_insert(scopes, collection)
1554                }
1555            }
1556        }
1557    }
1558}
1559
1560/// Combine matching RLS policies for a table + action into a single
1561/// `Filter` suitable for AND-ing into a caller's `WHERE` clause.
1562///
1563/// Returns `None` when RLS is disabled or no policy admits the caller's
1564/// role — callers use that to short-circuit the mutation (for DELETE /
1565/// UPDATE we simply skip the operation, which PG expresses as "no rows
1566/// match the policy + predicate combination").
1567pub(crate) fn rls_policy_filter(
1568    runtime: &RedDBRuntime,
1569    table: &str,
1570    action: crate::storage::query::ast::PolicyAction,
1571) -> Option<crate::storage::query::ast::Filter> {
1572    rls_policy_filter_for_kind(
1573        runtime,
1574        table,
1575        action,
1576        crate::storage::query::ast::PolicyTargetKind::Table,
1577    )
1578}
1579
1580/// Kind-aware policy filter combiner (Phase 2.5.5 RLS universal).
1581/// Graph / vector / queue / timeseries scans pass the concrete kind;
1582/// policies targeting other kinds are ignored. Legacy Table-scoped
1583/// policies still apply cross-kind — callers register auto-tenancy
1584/// policies as Table today.
1585pub(crate) fn rls_policy_filter_for_kind(
1586    runtime: &RedDBRuntime,
1587    table: &str,
1588    action: crate::storage::query::ast::PolicyAction,
1589    kind: crate::storage::query::ast::PolicyTargetKind,
1590) -> Option<crate::storage::query::ast::Filter> {
1591    use crate::storage::query::ast::Filter;
1592
1593    if !runtime.inner.rls_enabled_tables.read().contains(table) {
1594        return None;
1595    }
1596    let role = current_auth_identity().map(|(_, role)| role);
1597    let role_str = role.map(|r| r.as_str().to_string());
1598    let policies = runtime.matching_rls_policies_for_kind(table, role_str.as_deref(), action, kind);
1599    if policies.is_empty() {
1600        return None;
1601    }
1602    policies
1603        .into_iter()
1604        .reduce(|acc, f| Filter::Or(Box::new(acc), Box::new(f)))
1605}
1606
1607/// Returns true when the table has RLS enforcement enabled. Convenience
1608/// shortcut so DML paths can gate the AND-combine work without reaching
1609/// into `runtime.inner.rls_enabled_tables` directly.
1610pub(crate) fn rls_is_enabled(runtime: &RedDBRuntime, table: &str) -> bool {
1611    runtime.inner.rls_enabled_tables.read().contains(table)
1612}
1613
1614/// Per-entity gate used by the graph materialiser for `GraphNode`
1615/// entities. RLS is checked against the source collection with
1616/// `kind = Nodes`, which `matching_rls_policies_for_kind` resolves to
1617/// either `Nodes`-targeted policies or legacy `Table`-targeted ones
1618/// (for back-compat with auto-tenancy declarations). Cached per
1619/// collection so big graphs only resolve the policy chain once.
1620fn node_passes_rls(
1621    runtime: &RedDBRuntime,
1622    collection: &str,
1623    role: Option<&str>,
1624    cache: &mut std::collections::HashMap<String, Option<crate::storage::query::ast::Filter>>,
1625    entity: &crate::storage::unified::entity::UnifiedEntity,
1626) -> bool {
1627    use crate::storage::query::ast::{Filter, PolicyAction, PolicyTargetKind};
1628
1629    if !runtime.inner.rls_enabled_tables.read().contains(collection) {
1630        return true;
1631    }
1632    let filter = cache.entry(collection.to_string()).or_insert_with(|| {
1633        let policies = runtime.matching_rls_policies_for_kind(
1634            collection,
1635            role,
1636            PolicyAction::Select,
1637            PolicyTargetKind::Nodes,
1638        );
1639        if policies.is_empty() {
1640            None
1641        } else {
1642            policies
1643                .into_iter()
1644                .reduce(|acc, f| Filter::Or(Box::new(acc), Box::new(f)))
1645        }
1646    });
1647    let Some(filter) = filter else {
1648        return false;
1649    };
1650    crate::runtime::query_exec::evaluate_entity_filter_with_db(
1651        Some(&runtime.inner.db),
1652        entity,
1653        filter,
1654        collection,
1655        collection,
1656    )
1657}
1658
1659/// Edge counterpart of `node_passes_rls`. Same caching strategy with
1660/// `kind = Edges`.
1661fn edge_passes_rls(
1662    runtime: &RedDBRuntime,
1663    collection: &str,
1664    role: Option<&str>,
1665    cache: &mut std::collections::HashMap<String, Option<crate::storage::query::ast::Filter>>,
1666    entity: &crate::storage::unified::entity::UnifiedEntity,
1667) -> bool {
1668    use crate::storage::query::ast::{Filter, PolicyAction, PolicyTargetKind};
1669
1670    if !runtime.inner.rls_enabled_tables.read().contains(collection) {
1671        return true;
1672    }
1673    let filter = cache.entry(collection.to_string()).or_insert_with(|| {
1674        let policies = runtime.matching_rls_policies_for_kind(
1675            collection,
1676            role,
1677            PolicyAction::Select,
1678            PolicyTargetKind::Edges,
1679        );
1680        if policies.is_empty() {
1681            None
1682        } else {
1683            policies
1684                .into_iter()
1685                .reduce(|acc, f| Filter::Or(Box::new(acc), Box::new(f)))
1686        }
1687    });
1688    let Some(filter) = filter else {
1689        return false;
1690    };
1691    crate::runtime::query_exec::evaluate_entity_filter_with_db(
1692        Some(&runtime.inner.db),
1693        entity,
1694        filter,
1695        collection,
1696        collection,
1697    )
1698}
1699
1700/// RLS policy injection (Phase 2.5.2 PG parity).
1701///
1702/// Fetch every matching policy for the current thread-local role and
1703/// fold them into the query's filter. Semantics mirror PostgreSQL:
1704///
1705/// * Multiple policies on the same table combine with **OR** — a row is
1706///   visible if *any* policy admits it.
1707/// * The combined policy predicate is **AND**-ed into the caller's
1708///   existing `WHERE` clause so explicit predicates continue to trim
1709///   the policy-allowed set.
1710/// * No matching policies + RLS enabled = zero rows (PG's
1711///   restrictive-default). Callers get `None` and return an empty
1712///   `UnifiedResult` without ever dispatching the scan.
1713///
1714/// This runs only when `RuntimeInner::rls_enabled_tables` already
1715/// contains the table name — callers gate the hot path upfront to
1716/// avoid the lock acquisition on tables without RLS.
1717///
1718/// Returns `None` when no policy admits the current role; returns
1719/// `Some(mutated_table)` with policy filters folded in otherwise.
1720fn inject_rls_filters(
1721    runtime: &RedDBRuntime,
1722    frame: &dyn super::statement_frame::ReadFrame,
1723    mut table: crate::storage::query::ast::TableQuery,
1724) -> Option<crate::storage::query::ast::TableQuery> {
1725    use crate::storage::query::ast::{Filter, PolicyAction};
1726
1727    // `None` role falls through to policies with no `TO role` clause.
1728    let role = frame.identity().map(|(_, role)| role);
1729    let role_str = role.map(|r| r.as_str().to_string());
1730    let policies =
1731        runtime.matching_rls_policies(&table.table, role_str.as_deref(), PolicyAction::Select);
1732
1733    if policies.is_empty() {
1734        // RLS enabled + no policy match = deny everything. Signal the
1735        // caller to short-circuit with an empty result set.
1736        return None;
1737    }
1738
1739    // Combine policy predicates with OR (PG's permissive default).
1740    let combined = policies
1741        .into_iter()
1742        .reduce(|acc, f| Filter::Or(Box::new(acc), Box::new(f)))
1743        .expect("policies non-empty");
1744
1745    // AND into the caller's existing predicate. The predicate may live
1746    // in `where_expr` rather than `filter`: `resolve_table_expr_subqueries`
1747    // nulls `filter` whenever `where_expr` is present (the case for a
1748    // view body rewritten into `SELECT … WHERE …`). Folding only into
1749    // `filter` here would silently drop that `where_expr` predicate at
1750    // eval time because `effective_table_filter` prefers `filter` —
1751    // e.g. `WITHIN TENANT … SELECT * FROM <view>` would apply the
1752    // tenant policy but lose the view's own WHERE (#635).
1753    use crate::storage::query::sql_lowering::{expr_to_filter, filter_to_expr};
1754    let had_where_expr = table.where_expr.is_some();
1755    let existing = table
1756        .filter
1757        .take()
1758        .or_else(|| table.where_expr.as_ref().map(expr_to_filter));
1759    let new_filter = match existing {
1760        Some(existing) => Filter::And(Box::new(existing), Box::new(combined)),
1761        None => combined,
1762    };
1763    // Keep `where_expr` in lock-step with the merged `filter` so
1764    // whichever the executor consults sees the full predicate.
1765    if had_where_expr {
1766        table.where_expr = Some(filter_to_expr(&new_filter));
1767    }
1768    table.filter = Some(new_filter);
1769    Some(table)
1770}
1771
1772/// Apply per-table RLS to a `JoinQuery` by folding each side's policy
1773/// predicate into the join's outer filter. Walking the merged record
1774/// at the join layer (rather than mutating the per-side scan filter)
1775/// keeps the planner's strategy choice and per-side index selection
1776/// undisturbed — the policy predicate uses the qualified `t.col` form
1777/// that resolves cleanly against the merged record's keys.
1778///
1779/// Returns `None` when any leaf has RLS enabled and no policy admits
1780/// the caller — the join short-circuits to an empty result.
1781fn inject_rls_into_join(
1782    runtime: &RedDBRuntime,
1783    frame: &dyn super::statement_frame::ReadFrame,
1784    mut join: crate::storage::query::ast::JoinQuery,
1785) -> Option<crate::storage::query::ast::JoinQuery> {
1786    use crate::storage::query::ast::Filter;
1787
1788    let mut policy_filters: Vec<Filter> = Vec::new();
1789    if !collect_join_side_policy(runtime, frame, join.left.as_ref(), &mut policy_filters) {
1790        return None;
1791    }
1792    if !collect_join_side_policy(runtime, frame, join.right.as_ref(), &mut policy_filters) {
1793        return None;
1794    }
1795
1796    if policy_filters.is_empty() {
1797        return Some(join);
1798    }
1799
1800    let combined = policy_filters
1801        .into_iter()
1802        .reduce(|acc, f| Filter::And(Box::new(acc), Box::new(f)))
1803        .expect("policy_filters non-empty");
1804
1805    join.filter = Some(match join.filter.take() {
1806        Some(existing) => Filter::And(Box::new(existing), Box::new(combined)),
1807        None => combined,
1808    });
1809
1810    Some(join)
1811}
1812
1813/// For each `Table` leaf reachable through nested joins, append the
1814/// RLS-policy filter (combined with OR across that side's matching
1815/// policies) into `out`. Returns `false` when a side has RLS enabled
1816/// but no policy admits the caller — the join must short-circuit.
1817fn collect_join_side_policy(
1818    runtime: &RedDBRuntime,
1819    frame: &dyn super::statement_frame::ReadFrame,
1820    expr: &crate::storage::query::ast::QueryExpr,
1821    out: &mut Vec<crate::storage::query::ast::Filter>,
1822) -> bool {
1823    use crate::storage::query::ast::{Filter, PolicyAction, QueryExpr};
1824    match expr {
1825        QueryExpr::Table(t) => {
1826            if !runtime.inner.rls_enabled_tables.read().contains(&t.table) {
1827                return true;
1828            }
1829            let role = frame.identity().map(|(_, role)| role);
1830            let role_str = role.map(|r| r.as_str().to_string());
1831            let policies =
1832                runtime.matching_rls_policies(&t.table, role_str.as_deref(), PolicyAction::Select);
1833            if policies.is_empty() {
1834                return false;
1835            }
1836            let combined = policies
1837                .into_iter()
1838                .reduce(|acc, f| Filter::Or(Box::new(acc), Box::new(f)))
1839                .expect("policies non-empty");
1840            out.push(combined);
1841            true
1842        }
1843        QueryExpr::Join(inner) => {
1844            collect_join_side_policy(runtime, frame, inner.left.as_ref(), out)
1845                && collect_join_side_policy(runtime, frame, inner.right.as_ref(), out)
1846        }
1847        _ => true,
1848    }
1849}
1850
1851/// Foreign-table post-scan filter application (Phase 3.2.2 PG parity).
1852///
1853/// Phase 3.2 FDW wrappers don't advertise filter pushdown, so the runtime
1854/// applies `WHERE` / `ORDER BY` / `LIMIT` / `OFFSET` after the wrapper
1855/// materialises all rows. Projections are best-effort — when the query
1856/// lists explicit columns we keep only those; a `SELECT *` keeps every
1857/// wrapper-emitted field verbatim.
1858///
1859/// When a wrapper later opts into pushdown (`supports_pushdown = true`)
1860/// the runtime will pass the compiled filter down instead of post-filtering.
1861fn apply_foreign_table_filters(
1862    records: Vec<crate::storage::query::unified::UnifiedRecord>,
1863    query: &crate::storage::query::ast::TableQuery,
1864) -> crate::storage::query::unified::UnifiedResult {
1865    use crate::storage::query::sql_lowering::{
1866        effective_table_filter, effective_table_projections,
1867    };
1868    use crate::storage::query::unified::UnifiedResult;
1869
1870    let filter = effective_table_filter(query);
1871    let projections = effective_table_projections(query);
1872
1873    // Step 1 — WHERE. Reuse the cross-store evaluator so the semantics
1874    // match native-collection queries (same operators, same NULL handling).
1875    let mut filtered: Vec<_> = records
1876        .into_iter()
1877        .filter(|record| match &filter {
1878            Some(f) => {
1879                super::join_filter::evaluate_runtime_filter_with_db(None, record, f, None, None)
1880            }
1881            None => true,
1882        })
1883        .collect();
1884
1885    // Step 2 — LIMIT / OFFSET. Applied after filter to match SQL semantics.
1886    if let Some(offset) = query.offset {
1887        let offset = offset as usize;
1888        if offset >= filtered.len() {
1889            filtered.clear();
1890        } else {
1891            filtered.drain(0..offset);
1892        }
1893    }
1894    if let Some(limit) = query.limit {
1895        filtered.truncate(limit as usize);
1896    }
1897
1898    // Step 3 — columns list. `SELECT *` (no explicit projections) keeps
1899    // the wrapper's column set; an explicit list trims to those names.
1900    let columns: Vec<String> = if projections.is_empty() {
1901        filtered
1902            .first()
1903            .map(|r| r.column_names().iter().map(|k| k.to_string()).collect())
1904            .unwrap_or_default()
1905    } else {
1906        projections
1907            .iter()
1908            .map(super::join_filter::projection_name)
1909            .collect()
1910    };
1911
1912    let mut result = UnifiedResult::empty();
1913    result.columns = columns;
1914    result.records = filtered;
1915    result
1916}
1917
1918/// Collect every concrete table reference inside a `QueryExpr`.
1919///
1920/// Used by view bookkeeping (dependency tracking for materialised
1921/// invalidation) and any other rewriter that needs to know the base
1922/// tables a query pulls from. Does not descend into projections/filters;
1923/// only the `FROM` side.
1924pub(crate) fn collect_table_refs(expr: &QueryExpr) -> Vec<String> {
1925    let mut scopes: HashSet<String> = HashSet::new();
1926    collect_query_expr_result_cache_scopes(&mut scopes, expr);
1927    scopes.into_iter().collect()
1928}
1929
1930fn query_expr_result_cache_scopes(expr: &QueryExpr) -> HashSet<String> {
1931    let mut scopes = HashSet::new();
1932    collect_query_expr_result_cache_scopes(&mut scopes, expr);
1933    scopes
1934}
1935
1936/// Heuristic: does the raw SQL reference a built-in whose output
1937/// varies by connection, clock, or randomness? Such queries must
1938/// skip the 30s result cache — see the call site for rationale.
1939///
1940/// ASCII case-insensitive substring match. False positives (the
1941/// token appears in a quoted string) only skip caching, which is
1942/// the conservative direction.
1943/// If `sql` starts with `EXPLAIN` followed by a generic explainable statement,
1944/// return the trimmed inner statement; otherwise `None`.
1945///
1946/// `EXPLAIN ALTER FOR CREATE TABLE ...` is a separate schema-diff
1947/// command handled inside the normal SQL parser, so we leave it
1948/// alone here. `EXPLAIN ASK` and `EXPLAIN MIGRATION` are also executable
1949/// read paths handled by the parser/runtime directly.
1950fn strip_explain_prefix(sql: &str) -> Option<&str> {
1951    let trimmed = sql.trim_start();
1952    let (head, rest) = trimmed.split_at(
1953        trimmed
1954            .find(|c: char| c.is_whitespace())
1955            .unwrap_or(trimmed.len()),
1956    );
1957    if !head.eq_ignore_ascii_case("EXPLAIN") {
1958        return None;
1959    }
1960    let rest = rest.trim_start();
1961    if rest.is_empty() {
1962        return None;
1963    }
1964    // Peek the next token; command-specific EXPLAIN forms defer to
1965    // the normal parser.
1966    let next_head_end = rest.find(|c: char| c.is_whitespace()).unwrap_or(rest.len());
1967    if rest[..next_head_end].eq_ignore_ascii_case("ALTER")
1968        || rest[..next_head_end].eq_ignore_ascii_case("ASK")
1969        || rest[..next_head_end].eq_ignore_ascii_case("MIGRATION")
1970    {
1971        return None;
1972    }
1973    Some(rest)
1974}
1975
1976/// Cheap prefix check for a leading `WITH` keyword. Used to gate the
1977/// CTE-aware parse in `execute_query` without paying for a full
1978/// lexer pass on every statement. Treats `WITHIN` as not-a-CTE so
1979/// `WITHIN TENANT '...' SELECT ...` doesn't mis-route.
1980pub(super) fn has_with_prefix(sql: &str) -> bool {
1981    let trimmed = sql.trim_start();
1982    let head_end = trimmed
1983        .find(|c: char| c.is_whitespace() || c == '(')
1984        .unwrap_or(trimmed.len());
1985    trimmed[..head_end].eq_ignore_ascii_case("WITH")
1986}
1987
1988/// If the query is a plain SELECT whose top-level `TableQuery`
1989/// carries an `AS OF` clause, return a typed spec that the runtime
1990/// can feed to `vcs_resolve_as_of`. Returns `None` for any other
1991/// shape — joins, DML, EXPLAIN, or parse failures — so callers fall
1992/// back to the connection's regular MVCC snapshot. A cheap textual
1993/// prefilter skips the parse entirely when the source doesn't
1994/// mention `AS OF` / `as of`, keeping the autocommit hot path free.
1995fn peek_top_level_as_of(sql: &str) -> Option<crate::application::vcs::AsOfSpec> {
1996    peek_top_level_as_of_with_table(sql).map(|(spec, _)| spec)
1997}
1998
1999/// Same as `peek_top_level_as_of` but also returns the table name
2000/// targeted by the AS OF clause (when the FROM clause names a
2001/// concrete table). `None` for the table slot means scalar SELECT
2002/// or a subquery source — callers treat those as "no enforcement".
2003pub(super) fn peek_top_level_as_of_with_table(
2004    sql: &str,
2005) -> Option<(crate::application::vcs::AsOfSpec, Option<String>)> {
2006    if !sql
2007        .as_bytes()
2008        .windows(5)
2009        .any(|w| w.eq_ignore_ascii_case(b"as of"))
2010    {
2011        return None;
2012    }
2013    let parsed = crate::storage::query::parser::parse(sql).ok()?;
2014    let crate::storage::query::ast::QueryExpr::Table(table) = parsed.query else {
2015        return None;
2016    };
2017    let clause = table.as_of?;
2018    let table_name = if table.table.is_empty() || table.table == "any" {
2019        None
2020    } else {
2021        Some(table.table.clone())
2022    };
2023    let spec = match clause {
2024        crate::storage::query::ast::AsOfClause::Commit(h) => {
2025            crate::application::vcs::AsOfSpec::Commit(h)
2026        }
2027        crate::storage::query::ast::AsOfClause::Branch(b) => {
2028            crate::application::vcs::AsOfSpec::Branch(b)
2029        }
2030        crate::storage::query::ast::AsOfClause::Tag(t) => crate::application::vcs::AsOfSpec::Tag(t),
2031        crate::storage::query::ast::AsOfClause::TimestampMs(ts) => {
2032            crate::application::vcs::AsOfSpec::TimestampMs(ts)
2033        }
2034        crate::storage::query::ast::AsOfClause::Snapshot(x) => {
2035            crate::application::vcs::AsOfSpec::Snapshot(x)
2036        }
2037    };
2038    Some((spec, table_name))
2039}
2040
2041pub(super) fn query_has_volatile_builtin(sql: &str) -> bool {
2042    // Lowercase the bytes up to the first null/newline into a small
2043    // stack buffer for cheap contains() checks. Most SQL fits in the
2044    // buffer; longer queries fall back to owned lowercase.
2045    const VOLATILE_TOKENS: &[&str] = &[
2046        "pg_advisory_lock",
2047        "pg_try_advisory_lock",
2048        "pg_advisory_unlock",
2049        "random()",
2050        // `$config.<path>` / `$secret.<path>` resolve mutable runtime config /
2051        // vault state at execution time (#1370). A cached result would serve a
2052        // stale value after a later `SET CONFIG` / `SET SECRET`, so treat any
2053        // query referencing them as volatile (never result-cache it).
2054        "$config",
2055        "$secret",
2056        // NOW() / CURRENT_TIMESTAMP / CURRENT_DATE intentionally
2057        // omitted for now — they ARE volatile but today's tests rely
2058        // on caching them. Revisit once a tighter volatility story
2059        // lands.
2060    ];
2061    let lowered = sql.to_ascii_lowercase();
2062    VOLATILE_TOKENS.iter().any(|t| lowered.contains(t))
2063}
2064
2065pub(super) fn query_is_ask_statement(sql: &str) -> bool {
2066    let trimmed = sql.trim_start();
2067    let head_end = trimmed
2068        .find(|c: char| c.is_whitespace() || c == '(' || c == ';')
2069        .unwrap_or(trimmed.len());
2070    trimmed[..head_end].eq_ignore_ascii_case("ASK")
2071}
2072
2073/// Pick the `(global_mode, collection_mode)` pair for an expression,
2074/// or `None` for variants that opt out of intent-locking entirely
2075/// (admin statements like `SHOW CONFIG`, transaction control, tenant
2076/// toggles).
2077///
2078/// Phase-1 contract:
2079/// - Reads  — `(IX-compatible) (Global, IS) → (Collection, IS)`
2080/// - Writes — `(IX-compatible) (Global, IX) → (Collection, IX)`
2081/// - DDL    — `(strong)        (Global, IX) → (Collection, X)`
2082pub(super) fn intent_lock_modes_for(
2083    expr: &QueryExpr,
2084) -> Option<(
2085    crate::storage::transaction::lock::LockMode,
2086    crate::storage::transaction::lock::LockMode,
2087)> {
2088    use crate::storage::transaction::lock::LockMode::{Exclusive, IntentExclusive, IntentShared};
2089
2090    match expr {
2091        // Reads — IS / IS.
2092        QueryExpr::Table(_)
2093        | QueryExpr::Join(_)
2094        | QueryExpr::Vector(_)
2095        | QueryExpr::Hybrid(_)
2096        | QueryExpr::Graph(_)
2097        | QueryExpr::Path(_)
2098        | QueryExpr::Ask(_)
2099        | QueryExpr::SearchCommand(_)
2100        | QueryExpr::GraphCommand(_)
2101        | QueryExpr::RankOf(_)
2102        | QueryExpr::ApproxRankOf(_)
2103        | QueryExpr::RankRange(_)
2104        | QueryExpr::QueueSelect(_) => Some((IntentShared, IntentShared)),
2105
2106        // Writes — IX / IX. Non-tabular mutations (vector insert,
2107        // graph node insert, queue push, timeseries point insert)
2108        // don't carry their own dispatch arm here; they ride through
2109        // the Insert variant or a command variant covered by the
2110        // read-side arm above. P1.T4 expands only the TableQuery-ish
2111        // writes; non-tabular kinds inherit when their DML variants
2112        // land in later phases.
2113        QueryExpr::Insert(_)
2114        | QueryExpr::Update(_)
2115        | QueryExpr::Delete(_)
2116        | QueryExpr::QueueCommand(QueueCommand::Move { .. }) => {
2117            Some((IntentExclusive, IntentExclusive))
2118        }
2119        QueryExpr::QueueCommand(_) => Some((IntentShared, IntentShared)),
2120
2121        // DDL — IX / X. A DDL against collection `c` blocks all
2122        // other writers + readers on `c` but leaves other collections
2123        // running (because Global stays IX, not X).
2124        QueryExpr::CreateTable(_)
2125        | QueryExpr::CreateCollection(_)
2126        | QueryExpr::CreateVector(_)
2127        | QueryExpr::DropTable(_)
2128        | QueryExpr::DropGraph(_)
2129        | QueryExpr::DropVector(_)
2130        | QueryExpr::DropDocument(_)
2131        | QueryExpr::DropKv(_)
2132        | QueryExpr::DropCollection(_)
2133        | QueryExpr::Truncate(_)
2134        | QueryExpr::AlterTable(_)
2135        | QueryExpr::CreateIndex(_)
2136        | QueryExpr::DropIndex(_)
2137        | QueryExpr::CreateTimeSeries(_)
2138        | QueryExpr::CreateMetric(_)
2139        | QueryExpr::AlterMetric(_)
2140        | QueryExpr::CreateSlo(_)
2141        | QueryExpr::DropTimeSeries(_)
2142        | QueryExpr::CreateQueue(_)
2143        | QueryExpr::AlterQueue(_)
2144        | QueryExpr::DropQueue(_)
2145        | QueryExpr::CreateTree(_)
2146        | QueryExpr::DropTree(_)
2147        | QueryExpr::CreatePolicy(_)
2148        | QueryExpr::DropPolicy(_)
2149        | QueryExpr::CreateView(_)
2150        | QueryExpr::DropView(_)
2151        | QueryExpr::RefreshMaterializedView(_)
2152        | QueryExpr::CreateSchema(_)
2153        | QueryExpr::DropSchema(_)
2154        | QueryExpr::CreateSequence(_)
2155        | QueryExpr::DropSequence(_)
2156        | QueryExpr::CreateServer(_)
2157        | QueryExpr::DropServer(_)
2158        | QueryExpr::CreateForeignTable(_)
2159        | QueryExpr::DropForeignTable(_) => Some((IntentExclusive, Exclusive)),
2160
2161        // Admin / control — skip intent locks. `SET TENANT`,
2162        // `BEGIN / COMMIT / ROLLBACK`, `SET CONFIG`, `SHOW CONFIG`,
2163        // `VACUUM`, etc. don't touch collection data the same way
2164        // and the existing transaction layer already serialises the
2165        // pieces that matter.
2166        _ => None,
2167    }
2168}
2169
2170/// Best-effort collection inventory for an expression. Used to pick
2171/// `Collection(...)` resources for the intent-lock guard. Overshoots
2172/// are fine (take an extra IS, benign); undershoots leak writes past
2173/// DDL X locks, so err on the side of listing more names.
2174pub(super) fn collections_referenced(expr: &QueryExpr) -> Vec<String> {
2175    let mut out = Vec::new();
2176    walk_collections(expr, &mut out);
2177    out.sort();
2178    out.dedup();
2179    out
2180}
2181
2182fn walk_collections(expr: &QueryExpr, out: &mut Vec<String>) {
2183    match expr {
2184        QueryExpr::Table(t) => out.push(t.table.clone()),
2185        QueryExpr::Join(j) => {
2186            walk_collections(&j.left, out);
2187            walk_collections(&j.right, out);
2188        }
2189        QueryExpr::Insert(i) => out.push(i.table.clone()),
2190        QueryExpr::Update(u) => out.push(u.table.clone()),
2191        QueryExpr::Delete(d) => out.push(d.table.clone()),
2192        QueryExpr::QueueSelect(q) => out.push(q.queue.clone()),
2193
2194        // DDL — include the target collection so DDL takes
2195        // `(Collection, X)` and blocks concurrent readers / writers
2196        // on the same collection. Other collections stay live
2197        // because Global is still IX.
2198        QueryExpr::CreateTable(q) => out.push(q.name.clone()),
2199        QueryExpr::CreateCollection(q) => out.push(q.name.clone()),
2200        QueryExpr::CreateVector(q) => out.push(q.name.clone()),
2201        QueryExpr::DropTable(q) => out.push(q.name.clone()),
2202        QueryExpr::DropGraph(q) => out.push(q.name.clone()),
2203        QueryExpr::DropVector(q) => out.push(q.name.clone()),
2204        QueryExpr::DropDocument(q) => out.push(q.name.clone()),
2205        QueryExpr::DropKv(q) => out.push(q.name.clone()),
2206        QueryExpr::DropCollection(q) => out.push(q.name.clone()),
2207        QueryExpr::Truncate(q) => out.push(q.name.clone()),
2208        QueryExpr::AlterTable(q) => out.push(q.name.clone()),
2209        QueryExpr::CreateIndex(q) => out.push(q.table.clone()),
2210        QueryExpr::DropIndex(q) => out.push(q.table.clone()),
2211        QueryExpr::CreateTimeSeries(q) => out.push(q.name.clone()),
2212        QueryExpr::CreateMetric(q) => out.push(q.path.clone()),
2213        QueryExpr::AlterMetric(q) => out.push(q.path.clone()),
2214        QueryExpr::CreateSlo(q) => out.push(q.path.clone()),
2215        QueryExpr::DropTimeSeries(q) => out.push(q.name.clone()),
2216        QueryExpr::CreateQueue(q) => out.push(q.name.clone()),
2217        QueryExpr::AlterQueue(q) => out.push(q.name.clone()),
2218        QueryExpr::DropQueue(q) => out.push(q.name.clone()),
2219        QueryExpr::QueueCommand(QueueCommand::Move {
2220            source,
2221            destination,
2222            ..
2223        }) => {
2224            out.push(source.clone());
2225            out.push(destination.clone());
2226        }
2227        QueryExpr::CreatePolicy(q) => out.push(q.table.clone()),
2228        QueryExpr::CreateView(q) => out.push(q.name.clone()),
2229        QueryExpr::DropView(q) => out.push(q.name.clone()),
2230        QueryExpr::RefreshMaterializedView(q) => out.push(q.name.clone()),
2231
2232        // Vector / Hybrid / Graph / Path / commands reference
2233        // collections through fields whose shape varies; without a
2234        // uniform accessor we fall back to the global lock only —
2235        // benign because every runtime path still holds the global
2236        // mode.
2237        _ => {}
2238    }
2239}
2240
2241impl RedDBRuntime {
2242    pub fn in_memory() -> RedDBResult<Self> {
2243        Self::with_options(RedDBOptions::in_memory())
2244    }
2245
2246    pub fn flush(&self) -> RedDBResult<()> {
2247        self.inner
2248            .db
2249            .flush()
2250            .map_err(|err| RedDBError::Internal(err.to_string()))
2251    }
2252
2253    /// Handle to the intent-lock manager for tests + introspection.
2254    /// Production code acquires via `LockerGuard::new(rt.lock_manager())`
2255    /// rather than touching the manager directly.
2256    pub fn lock_manager(&self) -> std::sync::Arc<crate::storage::transaction::lock::LockManager> {
2257        self.inner.lock_manager.clone()
2258    }
2259
2260    /// Process-local governance registry for managed policy/config guardrails.
2261    pub fn config_registry(&self) -> std::sync::Arc<crate::auth::registry::ConfigRegistry> {
2262        self.inner.config_registry.clone()
2263    }
2264
2265    pub fn query_audit(&self) -> std::sync::Arc<crate::runtime::query_audit::QueryAuditStream> {
2266        self.inner.query_audit.clone()
2267    }
2268
2269    pub fn control_events_require_persistence(&self) -> bool {
2270        self.inner.control_event_config.require_persistence()
2271    }
2272
2273    pub fn control_event_config(&self) -> crate::runtime::control_events::ControlEventConfig {
2274        self.inner.control_event_config
2275    }
2276
2277    pub fn control_event_ledger(
2278        &self,
2279    ) -> Arc<dyn crate::runtime::control_events::ControlEventLedger> {
2280        self.inner.control_event_ledger.read().clone()
2281    }
2282
2283    #[doc(hidden)]
2284    pub fn replace_control_event_ledger_for_tests(
2285        &self,
2286        ledger: Arc<dyn crate::runtime::control_events::ControlEventLedger>,
2287    ) {
2288        *self.inner.control_event_ledger.write() = ledger;
2289    }
2290
2291    #[inline(never)]
2292    pub fn with_options(options: RedDBOptions) -> RedDBResult<Self> {
2293        Self::with_pool(options, ConnectionPoolConfig::default())
2294    }
2295
2296    pub fn with_pool(
2297        options: RedDBOptions,
2298        pool_config: ConnectionPoolConfig,
2299    ) -> RedDBResult<Self> {
2300        // PLAN.md Phase 9.1 — capture wall-clock before storage
2301        // open so the cold-start phase markers can be backfilled
2302        // once Lifecycle is constructed below. Storage open
2303        // encapsulates auto-restore + WAL replay; we treat the
2304        // whole window as one combined "restore" + "wal_replay"
2305        // phase split at the same boundary because the storage
2306        // layer doesn't yet emit a finer signal.
2307        let boot_open_start_ms = std::time::SystemTime::now()
2308            .duration_since(std::time::UNIX_EPOCH)
2309            .map(|d| d.as_millis() as u64)
2310            .unwrap_or(0);
2311        let embedded_single_file = options.storage_profile.deploy_profile
2312            == crate::storage::DeployProfile::Embedded
2313            && options.storage_profile.packaging == crate::storage::StoragePackaging::SingleFile;
2314        let db = Arc::new(
2315            RedDB::open_with_options(&options)
2316                .map_err(|err| RedDBError::Internal(err.to_string()))?,
2317        );
2318        let result_blob_cache_config = if embedded_single_file {
2319            crate::storage::cache::BlobCacheConfig::default()
2320        } else {
2321            crate::storage::cache::BlobCacheConfig::default().with_l2_path(
2322                reddb_file::layout::result_cache_l2_path(
2323                    &options.resolved_path(reddb_file::default_database_path()),
2324                ),
2325            )
2326        };
2327        let result_blob_cache =
2328            crate::storage::cache::BlobCache::open_with_l2(result_blob_cache_config).map_err(
2329                |err| RedDBError::Internal(format!("open result Blob Cache L2 failed: {err:?}")),
2330            )?;
2331        let storage_ready_ms = std::time::SystemTime::now()
2332            .duration_since(std::time::UNIX_EPOCH)
2333            .map(|d| d.as_millis() as u64)
2334            .unwrap_or(0);
2335
2336        let runtime = Self {
2337            inner: Arc::new(RuntimeInner {
2338                db: db.clone(),
2339                layout: PhysicalLayout::from_options(&options),
2340                embedded_single_file,
2341                indices: IndexCatalog::register_default_vector_graph(
2342                    options.has_capability(crate::api::Capability::Table),
2343                    options.has_capability(crate::api::Capability::Graph),
2344                ),
2345                pool_config,
2346                pool: Mutex::new(PoolState::default()),
2347                started_at_unix_ms: SystemTime::now()
2348                    .duration_since(UNIX_EPOCH)
2349                    .unwrap_or_default()
2350                    .as_millis(),
2351                probabilistic: super::probabilistic_store::ProbabilisticStore::new(),
2352                index_store: super::index_store::IndexStore::new(),
2353                cdc: crate::replication::cdc::CdcBuffer::new(100_000),
2354                backup_scheduler: crate::replication::scheduler::BackupScheduler::new(3600),
2355                query_cache: parking_lot::RwLock::new(
2356                    crate::storage::query::planner::cache::PlanCache::new(1000),
2357                ),
2358                result_cache: parking_lot::RwLock::new((
2359                    HashMap::new(),
2360                    std::collections::VecDeque::new(),
2361                )),
2362                result_blob_cache,
2363                result_blob_entries: parking_lot::RwLock::new((
2364                    HashMap::new(),
2365                    std::collections::VecDeque::new(),
2366                )),
2367                ask_answer_cache_entries: parking_lot::RwLock::new((
2368                    HashSet::new(),
2369                    std::collections::VecDeque::new(),
2370                )),
2371                result_cache_shadow_divergences: std::sync::atomic::AtomicU64::new(0),
2372                result_cache_hits: std::sync::atomic::AtomicU64::new(0),
2373                result_cache_misses: std::sync::atomic::AtomicU64::new(0),
2374                result_cache_evictions: std::sync::atomic::AtomicU64::new(0),
2375                ask_daily_spend: parking_lot::RwLock::new(HashMap::new()),
2376                queue_message_locks: parking_lot::RwLock::new(HashMap::new()),
2377                rmw_locks: RmwLockTable::new(),
2378                planner_dirty_tables: parking_lot::RwLock::new(HashSet::new()),
2379                ec_registry: Arc::new(crate::ec::config::EcRegistry::new()),
2380                config_registry: Arc::new(crate::auth::registry::ConfigRegistry::new()),
2381                ec_worker: crate::ec::worker::EcWorker::new(),
2382                auth_store: parking_lot::RwLock::new(None),
2383                oauth_validator: parking_lot::RwLock::new(None),
2384                browser_token_authority: parking_lot::RwLock::new(None),
2385                views: parking_lot::RwLock::new(HashMap::new()),
2386                materialized_views: parking_lot::RwLock::new(
2387                    crate::storage::cache::result::MaterializedViewCache::new(),
2388                ),
2389                retention_sweeper: parking_lot::RwLock::new(
2390                    crate::runtime::retention_sweeper::RetentionSweeperState::new(),
2391                ),
2392                snapshot_manager: Arc::new(
2393                    crate::storage::transaction::snapshot::SnapshotManager::new(),
2394                ),
2395                tx_contexts: parking_lot::RwLock::new(HashMap::new()),
2396                tx_local_tenants: parking_lot::RwLock::new(HashMap::new()),
2397                env_config_overrides: crate::runtime::config_overlay::collect_env_overrides(),
2398                lock_manager: Arc::new({
2399                    // Sourced from the matrix: Tier B key
2400                    // `concurrency.locking.deadlock_timeout_ms`
2401                    // (default 5000). Env var wins at boot so
2402                    // operators can tune without touching red_config.
2403                    let env = crate::runtime::config_overlay::collect_env_overrides();
2404                    let timeout_ms = env
2405                        .get("concurrency.locking.deadlock_timeout_ms")
2406                        .and_then(|raw| raw.parse::<u64>().ok())
2407                        .unwrap_or_else(|| {
2408                            match crate::runtime::config_matrix::default_for(
2409                                "concurrency.locking.deadlock_timeout_ms",
2410                            ) {
2411                                Some(crate::serde_json::Value::Number(n)) => n as u64,
2412                                _ => 5000,
2413                            }
2414                        });
2415                    let cfg = crate::storage::transaction::lock::LockConfig {
2416                        default_timeout: std::time::Duration::from_millis(timeout_ms),
2417                        ..Default::default()
2418                    };
2419                    crate::storage::transaction::lock::LockManager::new(cfg)
2420                }),
2421                rls_policies: parking_lot::RwLock::new(HashMap::new()),
2422                rls_enabled_tables: parking_lot::RwLock::new(HashSet::new()),
2423                foreign_tables: Arc::new(crate::storage::fdw::ForeignTableRegistry::with_builtins()),
2424                pending_tombstones: parking_lot::RwLock::new(HashMap::new()),
2425                pending_versioned_updates: parking_lot::RwLock::new(HashMap::new()),
2426                pending_kv_watch_events: parking_lot::RwLock::new(HashMap::new()),
2427                pending_store_wal_actions: parking_lot::RwLock::new(HashMap::new()),
2428                pending_claim_locks: parking_lot::RwLock::new(HashMap::new()),
2429                queue_wait_registry: std::sync::Arc::new(
2430                    crate::runtime::queue_wait_registry::QueueWaitRegistry::new(),
2431                ),
2432                pending_queue_wakes: parking_lot::RwLock::new(HashMap::new()),
2433                tenant_tables: parking_lot::RwLock::new(HashMap::new()),
2434                ddl_epoch: std::sync::atomic::AtomicU64::new(0),
2435                write_gate: Arc::new(crate::runtime::write_gate::WriteGate::from_options(
2436                    &options,
2437                )),
2438                lifecycle: crate::runtime::lifecycle::Lifecycle::new(),
2439                resource_limits: crate::runtime::resource_limits::ResourceLimits::from_env(),
2440                audit_log: {
2441                    // Default audit-log path for the in-memory case
2442                    // sits in the system temp dir; persistent runs
2443                    // place it next to the resolved data file.
2444                    //
2445                    // gh-471 iter 2: route through the resolved
2446                    // `LogDestination`. Performance/Max tiers emit a
2447                    // file-backed log destination under the file-owned
2448                    // support-directory logs tier;
2449                    // lower tiers / ephemeral runs report `Stderr`
2450                    // and we keep the legacy file-next-to-data sink.
2451                    // #1375 — single-file embedded mode keeps the data
2452                    // directory to exactly the `.rdb` artifact, so the audit
2453                    // log must NOT land as a sibling. Route it to a
2454                    // process-unique temp location even when a data path is
2455                    // set; only the non-embedded case uses the data dir.
2456                    let data_path = if embedded_single_file {
2457                        std::env::temp_dir()
2458                            .join("reddb-embedded-runtime")
2459                            .join(format!("audit-{}", std::process::id()))
2460                    } else {
2461                        options
2462                            .data_path
2463                            .clone()
2464                            .unwrap_or_else(|| std::env::temp_dir().join("reddb"))
2465                    };
2466                    let (audit_dest, _) = crate::api::tier_wiring::current_log_destinations();
2467                    if !matches!(audit_dest, crate::storage::layout::LogDestination::File(_))
2468                        && (embedded_single_file
2469                            || options
2470                                .metadata
2471                                .contains_key(crate::api::EPHEMERAL_RUNTIME_METADATA_KEY))
2472                    {
2473                        // The Stderr/Syslog lower-tier sink resolves to a
2474                        // `for_data_path` sibling that collides across concurrent
2475                        // temp-dir runtimes — nextest's process-per-test model
2476                        // truncates one shared file, flaking audit assertions.
2477                        // Pin a unique sibling for these short-lived ephemeral /
2478                        // single-file embedded runtimes. The file-owned support-
2479                        // dir tier (`File`) is already per-data unique, so leave
2480                        // it to `for_destination` (#1375: the embedded audit then
2481                        // still never lands a sibling next to the `.rdb`).
2482                        let audit_path = reddb_file::layout::sibling_path(
2483                            &data_path,
2484                            &reddb_file::layout::sidecar_file_name(&data_path, "audit.log"),
2485                        );
2486                        Arc::new(crate::runtime::audit_log::AuditLogger::with_path(
2487                            audit_path,
2488                        ))
2489                    } else {
2490                        Arc::new(crate::runtime::audit_log::AuditLogger::for_destination(
2491                            &audit_dest,
2492                            &data_path,
2493                        ))
2494                    }
2495                },
2496                control_event_ledger: parking_lot::RwLock::new(Arc::new(
2497                    crate::runtime::control_events::RuntimeLedger::new(db.store()),
2498                )),
2499                control_event_config: options.control_events,
2500                query_audit: Arc::new(crate::runtime::query_audit::QueryAuditStream::new(
2501                    db.store(),
2502                    options.query_audit.clone(),
2503                )),
2504                lease_lifecycle: std::sync::OnceLock::new(),
2505                replica_apply_metrics: std::sync::Arc::new(
2506                    crate::replication::logical::ReplicaApplyMetrics::default(),
2507                ),
2508                replica_link_metrics: std::sync::Arc::new(
2509                    crate::replication::reconnect::ReplicaLinkMetrics::default(),
2510                ),
2511                quota_bucket: crate::runtime::quota_bucket::QuotaBucket::from_env(),
2512                schema_vocabulary: parking_lot::RwLock::new(
2513                    crate::runtime::schema_vocabulary::SchemaVocabulary::new(),
2514                ),
2515                slow_query_logger: {
2516                    // Issue #205 — slow-query sink lives in the same
2517                    // directory the audit log uses, so backup/restore
2518                    // ships them together. Threshold + sample-pct
2519                    // default conservatively (1 s, 100% sampling) so
2520                    // emitted lines are rare and complete. Operators
2521                    // tune via env / config matrix in a follow-up.
2522                    //
2523                    // gh-471 iter 2: same routing as the audit log —
2524                    // `LogDestination::File(...)` for Performance/Max
2525                    // lands under the file-owned support-directory logs tier;
2526                    // lower tiers fall back to `red-slow.log` in the
2527                    // data directory.
2528                    // #1375 — see the audit-log note above: single-file mode
2529                    // never writes the slow-query log as a sibling of the
2530                    // `.rdb`. Route to a process-unique temp dir when embedded,
2531                    // regardless of the data path.
2532                    let fallback_dir = if embedded_single_file {
2533                        std::env::temp_dir()
2534                            .join("reddb-embedded-runtime")
2535                            .join(format!("slow-{}", std::process::id()))
2536                    } else {
2537                        options
2538                            .data_path
2539                            .as_ref()
2540                            .and_then(|p| p.parent().map(std::path::PathBuf::from))
2541                            .unwrap_or_else(|| std::env::temp_dir().join("reddb"))
2542                    };
2543                    let threshold_ms = std::env::var("RED_SLOW_QUERY_THRESHOLD_MS")
2544                        .ok()
2545                        .and_then(|s| s.parse::<u64>().ok())
2546                        .unwrap_or(1000);
2547                    let sample_pct = std::env::var("RED_SLOW_QUERY_SAMPLE_PCT")
2548                        .ok()
2549                        .and_then(|s| s.parse::<u8>().ok())
2550                        .unwrap_or(100);
2551                    let (_, slow_dest) = crate::api::tier_wiring::current_log_destinations();
2552                    crate::telemetry::slow_query_logger::SlowQueryLogger::for_destination(
2553                        &slow_dest,
2554                        &fallback_dir,
2555                        threshold_ms,
2556                        sample_pct,
2557                    )
2558                },
2559                slow_query_store: crate::telemetry::slow_query_store::SlowQueryStore::new(
2560                    crate::telemetry::slow_query_store::DEFAULT_CAP,
2561                ),
2562                kv_stats: crate::runtime::KvStatsCounters::default(),
2563                metrics_ingest_stats: crate::runtime::MetricsIngestCounters::default(),
2564                metrics_tenant_activity_stats:
2565                    crate::runtime::MetricsTenantActivityCounters::default(),
2566                claim_telemetry: Arc::new(
2567                    crate::runtime::claim_telemetry::ClaimTelemetryCounters::default(),
2568                ),
2569                queue_telemetry: Arc::new(
2570                    crate::runtime::queue_telemetry::QueueTelemetryCounters::default(),
2571                ),
2572                query_latency_telemetry: Arc::new(
2573                    crate::runtime::query_latency_telemetry::QueryLatencyTelemetry::default(),
2574                ),
2575                occupancy_sampler: Arc::new(
2576                    crate::runtime::occupancy_sampler::OccupancySampler::new(),
2577                ),
2578                node_load_telemetry: Arc::new(
2579                    crate::runtime::node_load_telemetry::NodeLoadTelemetry::default(),
2580                ),
2581                queue_presence: Arc::new(
2582                    crate::storage::queue::presence::ConsumerPresenceRegistry::new(),
2583                ),
2584                vector_introspection: Arc::new(
2585                    crate::storage::vector::introspection::VectorIntrospectionRegistry::new(),
2586                ),
2587                kv_tag_index: crate::runtime::KvTagIndex::default(),
2588                chain_tip_cache: parking_lot::Mutex::new(HashMap::new()),
2589                chain_integrity_broken: parking_lot::Mutex::new(HashMap::new()),
2590                integrity_tombstones: parking_lot::Mutex::new(Vec::new()),
2591                integrity_tombstones_state: std::sync::atomic::AtomicU8::new(0),
2592            }),
2593        };
2594
2595        // Issue #205 — install the process-wide OperatorEvent sink so
2596        // emit sites buried in storage / replication / signal handlers
2597        // can record without threading an `&AuditLogger` through every
2598        // call stack. First registration wins; subsequent in-memory
2599        // runtimes (test harnesses) fall through to tracing+eprintln.
2600        crate::telemetry::operator_event::install_global_audit_sink(Arc::clone(
2601            &runtime.inner.audit_log,
2602        ));
2603
2604        // Issue #1238 — wire the slow-query telemetry substrate (ADR 0060).
2605        // The logger dual-writes: file sink (existing) + ring store (new).
2606        runtime
2607            .inner
2608            .slow_query_logger
2609            .attach_store(Arc::clone(&runtime.inner.slow_query_store));
2610
2611        // PLAN.md Phase 9.1 — backfill cold-start phase markers
2612        // from the wall-clock captured before storage open. The
2613        // entire `RedDB::open_with_options` call covers both
2614        // auto-restore (when configured) and WAL replay. We
2615        // record both phases against the same boundary today;
2616        // a follow-up will split them once the storage layer
2617        // surfaces a finer-grained event.
2618        runtime
2619            .inner
2620            .lifecycle
2621            .set_restore_started_at_ms(boot_open_start_ms);
2622        runtime
2623            .inner
2624            .lifecycle
2625            .set_restore_ready_at_ms(storage_ready_ms);
2626        runtime
2627            .inner
2628            .lifecycle
2629            .set_wal_replay_started_at_ms(boot_open_start_ms);
2630        runtime
2631            .inner
2632            .lifecycle
2633            .set_wal_replay_ready_at_ms(storage_ready_ms);
2634
2635        let restored_cdc_lsn = runtime
2636            .inner
2637            .db
2638            .replication
2639            .as_ref()
2640            .map(|repl| {
2641                repl.logical_wal_spool
2642                    .as_ref()
2643                    .map(|spool| spool.current_lsn())
2644                    .unwrap_or(0)
2645            })
2646            .unwrap_or(0)
2647            .max(runtime.config_u64("red.config.timeline.last_archived_lsn", 0));
2648        runtime.inner.cdc.set_current_lsn(restored_cdc_lsn);
2649        runtime.rehydrate_snapshot_xid_floor();
2650        runtime
2651            .bootstrap_system_keyed_collections()
2652            .map_err(|err| RedDBError::Internal(format!("bootstrap system collections: {err}")))?;
2653        runtime.rehydrate_declared_column_schemas();
2654        runtime.rehydrate_runtime_index_registry()?;
2655        runtime
2656            .load_probabilistic_state()
2657            .map_err(|err| RedDBError::Internal(format!("load probabilistic state: {err}")))?;
2658
2659        // Phase 2.5.4: replay `tenant_tables.{table}.column` markers so
2660        // tables declared via `TENANT BY (col)` survive restart. Each
2661        // entry re-registers the auto-policy and flips RLS on again.
2662        runtime.rehydrate_tenant_tables();
2663        // Issue #593 slice 9a — replay persisted materialized-view
2664        // descriptors so `CREATE MATERIALIZED VIEW v AS …` survives a
2665        // restart. Runs after the system-keyed collections bootstrap
2666        // and before the API opens.
2667        runtime.rehydrate_materialized_view_descriptors();
2668        if let Some(repl) = &runtime.inner.db.replication {
2669            repl.wal_buffer.set_current_lsn(restored_cdc_lsn);
2670        }
2671
2672        // Save system info to red_config on boot
2673        {
2674            let sys = SystemInfo::collect();
2675            runtime.inner.db.store().set_config_tree(
2676                "red.system",
2677                &crate::serde_json::json!({
2678                    "pid": sys.pid,
2679                    "cpu_cores": sys.cpu_cores,
2680                    "total_memory_bytes": sys.total_memory_bytes,
2681                    "available_memory_bytes": sys.available_memory_bytes,
2682                    "os": sys.os,
2683                    "arch": sys.arch,
2684                    "hostname": sys.hostname,
2685                    "started_at": SystemTime::now()
2686                        .duration_since(UNIX_EPOCH)
2687                        .unwrap_or_default()
2688                        .as_millis() as u64
2689                }),
2690            );
2691
2692            // Seed defaults on first boot (only if red_config is empty or missing defaults)
2693            let store = runtime.inner.db.store();
2694            if store
2695                .get_collection("red_config")
2696                .map(|m| m.query_all(|_| true).len())
2697                .unwrap_or(0)
2698                <= 10
2699            {
2700                store.set_config_tree("red.ai", &crate::json!({
2701                    "default": crate::json!({
2702                        "provider": "openai",
2703                        "model": crate::ai::DEFAULT_OPENAI_PROMPT_MODEL
2704                    }),
2705                    "max_embedding_inputs": 256,
2706                    "max_prompt_batch": 256,
2707                    "timeout": crate::json!({ "connect_secs": 10, "read_secs": 90, "write_secs": 30 })
2708                }));
2709                store.set_config_tree(
2710                    "red.server",
2711                    &crate::json!({
2712                        "max_scan_limit": 1000,
2713                        "max_body_size": 1048576,
2714                        "read_timeout_ms": 5000,
2715                        "write_timeout_ms": 5000
2716                    }),
2717                );
2718                store.set_config_tree(
2719                    "red.storage",
2720                    &crate::json!({
2721                        "page_size": 4096,
2722                        "page_cache_capacity": 100000,
2723                        "auto_checkpoint_pages": 1000,
2724                        "snapshot_retention": 16,
2725                        "verify_checksums": true,
2726                        "segment": crate::json!({
2727                            "max_entities": 100000,
2728                            "max_bytes": 268435456_u64,
2729                            "compression_level": 6
2730                        }),
2731                        "hnsw": crate::json!({ "m": 16, "ef_construction": 100, "ef_search": 50 }),
2732                        "ivf": crate::json!({ "n_lists": 100, "n_probes": 10 }),
2733                        "bm25": crate::json!({ "k1": 1.2, "b": 0.75 })
2734                    }),
2735                );
2736                store.set_config_tree(
2737                    "red.search",
2738                    &crate::json!({
2739                        "rag": crate::json!({
2740                            "max_chunks_per_source": 10,
2741                            "max_total_chunks": 25,
2742                            "similarity_threshold": 0.8,
2743                            "graph_depth": 2,
2744                            "min_relevance": 0.3
2745                        }),
2746                        "fusion": crate::json!({
2747                            "vector_weight": 0.5,
2748                            "graph_weight": 0.3,
2749                            "table_weight": 0.2,
2750                            "dedup_threshold": 0.85
2751                        })
2752                    }),
2753                );
2754                store.set_config_tree(
2755                    "red.auth",
2756                    &crate::json!({
2757                        "enabled": false,
2758                        "session_ttl_secs": 3600,
2759                        "require_auth": false
2760                    }),
2761                );
2762                store.set_config_tree(
2763                    "red.query",
2764                    &crate::json!({
2765                        "connection_pool": crate::json!({ "max_connections": 64, "max_idle": 16 }),
2766                        "max_recursion_depth": 1000
2767                    }),
2768                );
2769                store.set_config_tree(
2770                    "red.indexes",
2771                    &crate::json!({
2772                        "auto_select": true,
2773                        "bloom_filter": crate::json!({
2774                            "enabled": true,
2775                            "false_positive_rate": 0.01,
2776                            "prune_on_scan": true
2777                        }),
2778                        "hash": crate::json!({ "enabled": true }),
2779                        "bitmap": crate::json!({ "enabled": true, "max_cardinality": 1000 }),
2780                        "spatial": crate::json!({ "enabled": true })
2781                    }),
2782                );
2783                store.set_config_tree(
2784                    "red.memtable",
2785                    &crate::json!({
2786                        "enabled": true,
2787                        "max_bytes": 67108864_u64,
2788                        "flush_threshold": 0.75
2789                    }),
2790                );
2791                store.set_config_tree(
2792                    "red.probabilistic",
2793                    &crate::json!({
2794                        "hll_registers": 16384,
2795                        "sketch_default_width": 1000,
2796                        "sketch_default_depth": 5,
2797                        "filter_default_capacity": 100000
2798                    }),
2799                );
2800                store.set_config_tree(
2801                    "red.timeseries",
2802                    &crate::json!({
2803                        "default_chunk_size": 1024,
2804                        "compression": crate::json!({
2805                            "timestamps": "delta_of_delta",
2806                            "values": "gorilla_xor"
2807                        }),
2808                        "default_retention_days": 0
2809                    }),
2810                );
2811                store.set_config_tree(
2812                    "red.queue",
2813                    &crate::json!({
2814                        "default_max_size": 0,
2815                        "default_max_attempts": 3,
2816                        "visibility_timeout_ms": 30000,
2817                        "consumer_idle_timeout_ms": 60000
2818                    }),
2819                );
2820                store.set_config_tree(
2821                    "red.backup",
2822                    &crate::json!({
2823                        "enabled": false,
2824                        "interval_secs": 3600,
2825                        "retention_count": 24,
2826                        "upload": false,
2827                        "backend": "local"
2828                    }),
2829                );
2830                store.set_config_tree(
2831                    "red.wal",
2832                    &crate::json!({
2833                        "archive": crate::json!({
2834                            "enabled": false,
2835                            "retention_hours": 168,
2836                            "prefix": reddb_file::backup_wal_prefix("")
2837                        })
2838                    }),
2839                );
2840                store.set_config_tree(
2841                    "red.cdc",
2842                    &crate::json!({
2843                        "enabled": true,
2844                        "buffer_size": 100000
2845                    }),
2846                );
2847                store.set_config_tree(
2848                    "red.config.secret",
2849                    &crate::json!({
2850                        "auto_encrypt": true,
2851                        "auto_decrypt": true
2852                    }),
2853                );
2854            }
2855
2856            // Perf-parity config matrix: heal the Tier A (critical)
2857            // keys unconditionally on every boot. Idempotent — only
2858            // writes the default when the key is missing. Keeps
2859            // `SHOW CONFIG` showing every guarantee the operator has
2860            // (durability.mode, concurrency.locking.enabled, …) even
2861            // on long-running datadirs that predate the matrix.
2862            crate::runtime::config_matrix::heal_critical_keys(store.as_ref());
2863            seed_storage_deploy_config(store.as_ref(), options.storage_profile);
2864
2865            // Phase 5 — Lehman-Yao runtime flag. Read the Tier A
2866            // `storage.btree.lehman_yao` value from the matrix (env
2867            // > file > red_config > default) and publish it to the
2868            // storage layer's atomic so the B-tree read / split
2869            // paths can branch without re-reading the config on
2870            // every hot-path call.
2871            let lehman_yao = runtime.config_bool("storage.btree.lehman_yao", true);
2872            crate::storage::engine::btree::lehman_yao::set_enabled(lehman_yao);
2873            if lehman_yao {
2874                tracing::info!(
2875                    "storage.btree.lehman_yao=true — lock-free concurrent descent enabled"
2876                );
2877            }
2878
2879            // Config file overlay — mounted `/etc/reddb/config.json`
2880            // (override path via REDDB_CONFIG_FILE). Writes keys with
2881            // write-if-absent semantics so a later user `SET CONFIG`
2882            // always wins. Missing file = silent no-op.
2883            let overlay_path = crate::runtime::config_overlay::config_file_path();
2884            let _ =
2885                crate::runtime::config_overlay::apply_config_file(store.as_ref(), &overlay_path);
2886        }
2887
2888        // VCS ("Git for Data") — create the `red_*` metadata
2889        // collections on first boot. Idempotent: `get_or_create_collection`
2890        // is a no-op if the collection already exists.
2891        {
2892            let store = runtime.inner.db.store();
2893            for name in crate::application::vcs_collections::ALL {
2894                let _ = store.get_or_create_collection(*name);
2895            }
2896            // Seed VCS config namespace with sensible defaults on first
2897            // boot, matching the pattern used by red.ai / red.storage.
2898            store.set_config_tree(
2899                crate::application::vcs_collections::CONFIG_NAMESPACE,
2900                &crate::json!({
2901                    "default_branch": "main",
2902                    "author": crate::json!({
2903                        "name": "reddb",
2904                        "email": "reddb@localhost"
2905                    }),
2906                    "protected_branches": crate::json!(["main"]),
2907                    "closure": crate::json!({
2908                        "enabled": true,
2909                        "lazy": true
2910                    }),
2911                    "merge": crate::json!({
2912                        "default_strategy": "auto",
2913                        "fast_forward": true
2914                    })
2915                }),
2916            );
2917        }
2918
2919        // Migrations — create the `red_migrations` / `red_migration_deps`
2920        // system collections on first boot. Idempotent.
2921        {
2922            let store = runtime.inner.db.store();
2923            for name in crate::application::migration_collections::ALL {
2924                let _ = store.get_or_create_collection(*name);
2925            }
2926        }
2927
2928        // Topology graph (#803) — ensure the built-in `red.topology.cluster`
2929        // graph collection (declared WITH ANALYTICS) and its metadata sidecar
2930        // exist. Idempotent and survives restarts via the WAL-backed contract.
2931        let _ = crate::application::topology_collections::ensure(&runtime);
2932
2933        // #1369 — reserve a fixed internal-id floor so the first user-inserted
2934        // entity always receives a stable, documented `rid` (FIRST_USER_ENTITY_ID),
2935        // independent of how many internal collection-descriptor / config-default
2936        // entities the boot sequence seeded above. `register_entity_id` only ever
2937        // raises the allocator, so a database that already holds user data
2938        // (counter past the floor) is untouched; a freshly-seeded database jumps
2939        // straight to the floor.
2940        runtime
2941            .inner
2942            .db
2943            .store()
2944            .register_entity_id(crate::storage::EntityId::new(
2945                crate::storage::FIRST_USER_ENTITY_ID - 1,
2946            ));
2947
2948        // Start background maintenance thread (context index refresh +
2949        // session purge). Held by a WEAK reference to `RuntimeInner`
2950        // so dropping the last `RedDBRuntime` handle actually releases
2951        // the underlying Arc<Pager> (and its file lock). Polling at
2952        // 200ms means shutdown latency is bounded; the real 60-second
2953        // work cadence is tracked independently via a `last_work`
2954        // timestamp.
2955        //
2956        // The previous version captured `rt = runtime.clone()` by
2957        // strong reference and ran an unterminated `loop`, which held
2958        // Arc<RuntimeInner> forever — reopening a persistent database
2959        // in the same process failed with "Database is locked" because
2960        // the pager could never drop. See the regression test
2961        // `finding_1_select_after_bulk_insert_persistent_reopen`.
2962        {
2963            let weak = Arc::downgrade(&runtime.inner);
2964            std::thread::Builder::new()
2965                .name("reddb-maintenance".into())
2966                .spawn(move || {
2967                    let tick = std::time::Duration::from_millis(200);
2968                    let work_interval = std::time::Duration::from_secs(60);
2969                    let mut last_work = std::time::Instant::now();
2970                    loop {
2971                        std::thread::sleep(tick);
2972                        let Some(inner) = weak.upgrade() else {
2973                            // All strong references dropped — the
2974                            // runtime is gone, exit cleanly.
2975                            break;
2976                        };
2977                        if last_work.elapsed() >= work_interval {
2978                            let _stats = inner.db.store().context_index().stats();
2979                            last_work = std::time::Instant::now();
2980                        }
2981                    }
2982                })
2983                .ok();
2984        }
2985
2986        // Start backup scheduler if enabled via red_config
2987        {
2988            let store = runtime.inner.db.store();
2989            let mut backup_enabled = false;
2990            let mut backup_interval = 3600u64;
2991
2992            if let Some(manager) = store.get_collection("red_config") {
2993                manager.for_each_entity(|entity| {
2994                    if let Some(row) = entity.data.as_row() {
2995                        let key = row.get_field("key").and_then(|v| match v {
2996                            crate::storage::schema::Value::Text(s) => Some(s.as_ref()),
2997                            _ => None,
2998                        });
2999                        let val = row.get_field("value");
3000                        if key == Some("red.config.backup.enabled") {
3001                            backup_enabled = match val {
3002                                Some(crate::storage::schema::Value::Boolean(true)) => true,
3003                                Some(crate::storage::schema::Value::Text(s)) => &**s == "true",
3004                                _ => false,
3005                            };
3006                        } else if key == Some("red.config.backup.interval_secs") {
3007                            if let Some(crate::storage::schema::Value::Integer(n)) = val {
3008                                backup_interval = *n as u64;
3009                            }
3010                        }
3011                    }
3012                    true
3013                });
3014            }
3015
3016            if backup_enabled {
3017                runtime.inner.backup_scheduler.set_interval(backup_interval);
3018                let rt = runtime.clone();
3019                runtime
3020                    .inner
3021                    .backup_scheduler
3022                    .start(move || rt.trigger_backup().map_err(|e| format!("{}", e)));
3023            }
3024        }
3025
3026        // Load EC registry from red_config and start worker
3027        {
3028            runtime
3029                .inner
3030                .ec_registry
3031                .load_from_config_store(runtime.inner.db.store().as_ref());
3032            if !runtime.inner.ec_registry.async_configs().is_empty() {
3033                runtime.inner.ec_worker.start(
3034                    Arc::clone(&runtime.inner.ec_registry),
3035                    Arc::clone(&runtime.inner.db.store()),
3036                );
3037            }
3038        }
3039
3040        if let crate::replication::ReplicationRole::Replica { primary_addr } =
3041            runtime.inner.db.options().replication.role.clone()
3042        {
3043            let rt = runtime.clone();
3044            std::thread::Builder::new()
3045                .name("reddb-replica".into())
3046                .spawn(move || rt.run_replica_loop(primary_addr))
3047                .ok();
3048        }
3049
3050        // PLAN.md Phase 1 — Lifecycle Contract. Mark Ready once every
3051        // boot stage above has completed (WAL replay, restore-from-
3052        // remote, replica-loop spawn). Health probes flip from 503 to
3053        // 200 here; shutdown begins from this state.
3054        runtime.inner.lifecycle.mark_ready();
3055
3056        // Issue #583 slice 10 — ContinuousMaterializedView scheduler.
3057        // Low-priority background ticker that drains the cache's
3058        // `claim_due_at` set every ~50ms. Holds only a Weak<RuntimeInner>
3059        // so the thread exits cleanly when the runtime drops (≤50ms
3060        // latency between drop and exit). Materialized views without
3061        // a `REFRESH EVERY` clause stay on the manual-refresh path
3062        // and are skipped by `claim_due_at`, so the loop is a no-op
3063        // when no scheduled views exist.
3064        {
3065            let weak_inner = Arc::downgrade(&runtime.inner);
3066            std::thread::Builder::new()
3067                .name("reddb-mv-scheduler".into())
3068                .spawn(move || loop {
3069                    std::thread::sleep(std::time::Duration::from_millis(50));
3070                    let Some(inner) = weak_inner.upgrade() else {
3071                        break;
3072                    };
3073                    let rt = RedDBRuntime { inner };
3074                    rt.refresh_due_materialized_views();
3075                })
3076                .ok();
3077        }
3078
3079        // Issue #584 slice 12 — DeclarativeRetention background sweeper.
3080        // Low-priority ticker that physically reclaims rows whose
3081        // timestamp has fallen beyond the retention window. Holds a
3082        // `Weak<RuntimeInner>` so the thread exits within one tick of
3083        // the runtime drop (graceful shutdown leaves storage consistent
3084        // because each tick goes through the standard DELETE path —
3085        // there is no half-finished mutation state to clean up). The
3086        // tick interval is intentionally longer than the MV scheduler
3087        // (500ms) because retention is order-of-seconds at minimum.
3088        if !runtime.write_gate().is_read_only() {
3089            let weak_inner = Arc::downgrade(&runtime.inner);
3090            std::thread::Builder::new()
3091                .name("reddb-retention-sweeper".into())
3092                .spawn(move || loop {
3093                    std::thread::sleep(std::time::Duration::from_millis(500));
3094                    let Some(inner) = weak_inner.upgrade() else {
3095                        break;
3096                    };
3097                    let rt = RedDBRuntime { inner };
3098                    rt.sweep_retention_tick(
3099                        crate::runtime::retention_sweeper::DEFAULT_SWEEPER_BATCH,
3100                    );
3101                })
3102                .ok();
3103        }
3104
3105        Ok(runtime)
3106    }
3107
3108    fn rehydrate_snapshot_xid_floor(&self) {
3109        let store = self.inner.db.store();
3110        for collection in store.list_collections() {
3111            let Some(manager) = store.get_collection(&collection) else {
3112                continue;
3113            };
3114            for entity in manager.query_all(|_| true) {
3115                self.inner
3116                    .snapshot_manager
3117                    .observe_committed_xid(entity.xmin);
3118                self.inner
3119                    .snapshot_manager
3120                    .observe_committed_xid(entity.xmax);
3121            }
3122        }
3123    }
3124
3125    /// Provision an empty Table-shaped collection that backs a
3126    /// `CREATE MATERIALIZED VIEW v` (issue #594 slice 9b of #575).
3127    /// `SELECT FROM v` reads this collection directly; the rewriter is
3128    /// configured to skip materialized views so the body is no longer
3129    /// substituted. REFRESH still writes to the cache slot — wiring it
3130    /// into this backing collection is the job of slice 9c.
3131    ///
3132    /// Idempotent: re-running for the same name leaves the existing
3133    /// collection in place (mirrors `CREATE TABLE IF NOT EXISTS`
3134    /// semantics). This keeps `CREATE OR REPLACE MATERIALIZED VIEW v`
3135    /// cheap — the body change does not invalidate already-buffered
3136    /// rows. Until 9c lands the backing is always empty anyway.
3137    pub(crate) fn ensure_materialized_view_backing(&self, name: &str) -> RedDBResult<()> {
3138        let store = self.inner.db.store();
3139        let mut changed = false;
3140        if store.get_collection(name).is_none() {
3141            store.get_or_create_collection(name);
3142            changed = true;
3143        }
3144        if self.inner.db.collection_contract(name).is_none() {
3145            self.inner
3146                .db
3147                .save_collection_contract(system_keyed_collection_contract(
3148                    name,
3149                    crate::catalog::CollectionModel::Table,
3150                ))
3151                .map_err(|err| RedDBError::Internal(err.to_string()))?;
3152            changed = true;
3153        }
3154        if changed {
3155            self.inner
3156                .db
3157                .persist_metadata()
3158                .map_err(|err| RedDBError::Internal(err.to_string()))?;
3159        }
3160        Ok(())
3161    }
3162
3163    /// Inverse of [`ensure_materialized_view_backing`] — drops the
3164    /// backing collection on `DROP MATERIALIZED VIEW v`. No-op when
3165    /// the collection was never created (e.g. a `DROP MATERIALIZED
3166    /// VIEW IF EXISTS v` against an unknown name).
3167    pub(crate) fn drop_materialized_view_backing(&self, name: &str) -> RedDBResult<()> {
3168        let store = self.inner.db.store();
3169        if store.get_collection(name).is_none() {
3170            return Ok(());
3171        }
3172        store
3173            .drop_collection(name)
3174            .map_err(|err| RedDBError::Internal(err.to_string()))?;
3175        // The contract may have been dropped already (DROP TABLE path)
3176        // — ignore "not found" errors by checking presence first.
3177        if self.inner.db.collection_contract(name).is_some() {
3178            self.inner
3179                .db
3180                .remove_collection_contract(name)
3181                .map_err(|err| RedDBError::Internal(err.to_string()))?;
3182        }
3183        self.invalidate_result_cache();
3184        self.inner
3185            .db
3186            .persist_metadata()
3187            .map_err(|err| RedDBError::Internal(err.to_string()))?;
3188        Ok(())
3189    }
3190
3191    fn bootstrap_system_keyed_collections(&self) -> RedDBResult<()> {
3192        let mut changed = false;
3193        for (name, model) in [
3194            ("red.config", crate::catalog::CollectionModel::Config),
3195            ("red.vault", crate::catalog::CollectionModel::Vault),
3196            // Issue #593 — materialized-view catalog. One row per
3197            // `CREATE MATERIALIZED VIEW`; rehydrated at boot before
3198            // the API opens.
3199            (
3200                crate::runtime::continuous_materialized_view::CATALOG_COLLECTION,
3201                crate::catalog::CollectionModel::Config,
3202            ),
3203        ] {
3204            if self.inner.db.store().get_collection(name).is_none() {
3205                self.inner.db.store().get_or_create_collection(name);
3206                changed = true;
3207            }
3208            if self.inner.db.collection_contract(name).is_none() {
3209                self.inner
3210                    .db
3211                    .save_collection_contract(system_keyed_collection_contract(name, model))
3212                    .map_err(|err| RedDBError::Internal(err.to_string()))?;
3213                changed = true;
3214            }
3215        }
3216        if changed {
3217            self.inner
3218                .db
3219                .persist_metadata()
3220                .map_err(|err| RedDBError::Internal(err.to_string()))?;
3221        }
3222        Ok(())
3223    }
3224
3225    pub fn db(&self) -> Arc<RedDB> {
3226        Arc::clone(&self.inner.db)
3227    }
3228
3229    /// Direct access to the runtime's secondary-index store.
3230    /// Used by bulk-insert entry points (gRPC binary bulk, HTTP bulk,
3231    /// wire bulk) that need to push new rows through the per-index
3232    /// maintenance hook after `store.bulk_insert` returns.
3233    pub fn index_store_ref(&self) -> &super::index_store::IndexStore {
3234        &self.inner.index_store
3235    }
3236
3237    /// Apply a DDL event to the schema-vocabulary reverse index
3238    /// (issue #120). Called by DDL execution paths after the catalog
3239    /// mutation has succeeded so the index never holds entries for
3240    /// half-applied DDL.
3241    pub(crate) fn schema_vocabulary_apply(
3242        &self,
3243        event: crate::runtime::schema_vocabulary::DdlEvent,
3244    ) {
3245        self.inner.schema_vocabulary.write().on_ddl(event);
3246    }
3247
3248    /// Lookup `token` in the schema-vocabulary reverse index. Returns
3249    /// an owned `Vec<VocabHit>` because the underlying read lock
3250    /// cannot be borrowed across the call boundary; the slice from
3251    /// `SchemaVocabulary::lookup` is cloned per hit.
3252    pub fn schema_vocabulary_lookup(
3253        &self,
3254        token: &str,
3255    ) -> Vec<crate::runtime::schema_vocabulary::VocabHit> {
3256        self.inner.schema_vocabulary.read().lookup(token).to_vec()
3257    }
3258
3259    /// Inject an AuthStore into the runtime. Called by server boot
3260    /// after the vault has been bootstrapped, so that `Value::Secret`
3261    /// auto-encrypt/decrypt can reach the vault AES key.
3262    pub fn set_auth_store(&self, store: Arc<crate::auth::store::AuthStore>) {
3263        *self.inner.auth_store.write() = Some(store);
3264    }
3265
3266    /// Snapshot the current AuthStore (if any). Used by the wire listener
3267    /// to validate bearer tokens issued via HTTP `/auth/login`.
3268    pub fn auth_store(&self) -> Option<Arc<crate::auth::store::AuthStore>> {
3269        self.inner.auth_store.read().clone()
3270    }
3271
3272    /// Read a vault KV secret from the configured AuthStore, if present.
3273    pub fn vault_kv_get(&self, key: &str) -> Option<String> {
3274        self.inner
3275            .auth_store
3276            .read()
3277            .as_ref()
3278            .and_then(|store| store.vault_kv_get(key))
3279    }
3280
3281    /// Write a vault KV secret and fail if the encrypted vault write is
3282    /// unavailable or cannot be made durable.
3283    pub fn vault_kv_try_set(&self, key: String, value: String) -> RedDBResult<()> {
3284        let store = self.inner.auth_store.read().clone().ok_or_else(|| {
3285            RedDBError::Query("secret storage requires an enabled, unsealed vault".to_string())
3286        })?;
3287        store
3288            .vault_kv_try_set(key, value)
3289            .map_err(|err| RedDBError::Query(err.to_string()))
3290    }
3291
3292    /// Inject an `OAuthValidator` into the runtime. When set, HTTP and
3293    /// wire transports try OAuth JWT validation before falling back to
3294    /// the local AuthStore lookup. Pass `None` to disable.
3295    pub fn set_oauth_validator(&self, validator: Option<Arc<crate::auth::oauth::OAuthValidator>>) {
3296        *self.inner.oauth_validator.write() = validator;
3297    }
3298
3299    /// Returns a clone of the configured `OAuthValidator` Arc, if any.
3300    /// Hot path: called per HTTP request when an Authorization header
3301    /// is present, so we hand back a cheap Arc clone.
3302    pub fn oauth_validator(&self) -> Option<Arc<crate::auth::oauth::OAuthValidator>> {
3303        self.inner.oauth_validator.read().clone()
3304    }
3305
3306    /// Inject the browser-token authority (issue #936). When set, the
3307    /// RedWire WS handshake accepts the short-lived access JWT it mints
3308    /// (alongside, and tried before, the federated OAuth validator), and
3309    /// the `/auth/browser/*` HTTP endpoints can issue/rotate the pair.
3310    /// `None` leaves the browser credential flow inert.
3311    pub fn set_browser_token_authority(
3312        &self,
3313        authority: Option<Arc<crate::auth::browser_token::BrowserTokenAuthority>>,
3314    ) {
3315        *self.inner.browser_token_authority.write() = authority;
3316    }
3317
3318    /// Snapshot the browser-token authority, if wired. Read on the WS
3319    /// handshake path and by the `/auth/browser/*` handlers; a cheap Arc
3320    /// clone keeps the lock hold short.
3321    pub fn browser_token_authority(
3322        &self,
3323    ) -> Option<Arc<crate::auth::browser_token::BrowserTokenAuthority>> {
3324        self.inner.browser_token_authority.read().clone()
3325    }
3326
3327    /// Returns the vault AES key (`red.secret.aes_key`) if an auth
3328    /// store is wired and a key has been generated. Used by the
3329    /// `Value::Secret` encrypt/decrypt pipeline.
3330    pub(crate) fn secret_aes_key(&self) -> Option<[u8; 32]> {
3331        let guard = self.inner.auth_store.read();
3332        guard.as_ref().and_then(|s| s.vault_secret_key())
3333    }
3334
3335    /// Resolve a boolean flag from `red_config`. Defaults to `default`
3336    /// when the key is missing or not coercible. If the same key has
3337    /// been written multiple times (SET CONFIG appends new rows), the
3338    /// most recent entity wins. Env-var overrides
3339    /// (`REDDB_<UP_DOTTED>`) take highest precedence.
3340    pub(crate) fn config_bool(&self, key: &str, default: bool) -> bool {
3341        if let Some(raw) = self.inner.env_config_overrides.get(key) {
3342            if let Some(crate::storage::schema::Value::Boolean(b)) =
3343                crate::runtime::config_overlay::coerce_env_value(key, raw)
3344            {
3345                return b;
3346            }
3347        }
3348        let store = self.inner.db.store();
3349        let Some(manager) = store.get_collection("red_config") else {
3350            return default;
3351        };
3352        let mut result = default;
3353        let mut latest_id: u64 = 0;
3354        manager.for_each_entity(|entity| {
3355            if let Some(row) = entity.data.as_row() {
3356                let entry_key = row.get_field("key").and_then(|v| match v {
3357                    crate::storage::schema::Value::Text(s) => Some(s.as_ref()),
3358                    _ => None,
3359                });
3360                if entry_key == Some(key) {
3361                    let id = entity.id.raw();
3362                    if id >= latest_id {
3363                        latest_id = id;
3364                        result = match row.get_field("value") {
3365                            Some(crate::storage::schema::Value::Boolean(b)) => *b,
3366                            Some(crate::storage::schema::Value::Text(s)) => {
3367                                matches!(s.as_ref(), "true" | "TRUE" | "True" | "1")
3368                            }
3369                            Some(crate::storage::schema::Value::Integer(n)) => *n != 0,
3370                            _ => default,
3371                        };
3372                    }
3373                }
3374            }
3375            true
3376        });
3377        result
3378    }
3379
3380    /// Whether DOCUMENT writes should store the body as the native binary
3381    /// container (PRD-1398, ADR-0063). Off by default; flip via
3382    /// `SET CONFIG storage.binary_document_body = true` or the
3383    /// `REDDB_STORAGE_BINARY_DOCUMENT_BODY` env var. Reads decode the container
3384    /// transparently regardless of this flag.
3385    pub(crate) fn binary_document_body_enabled(&self) -> bool {
3386        self.config_bool("storage.binary_document_body", false)
3387    }
3388
3389    pub(crate) fn config_u64(&self, key: &str, default: u64) -> u64 {
3390        if let Some(raw) = self.inner.env_config_overrides.get(key) {
3391            if let Some(crate::storage::schema::Value::UnsignedInteger(n)) =
3392                crate::runtime::config_overlay::coerce_env_value(key, raw)
3393            {
3394                return n;
3395            }
3396        }
3397        let store = self.inner.db.store();
3398        let Some(manager) = store.get_collection("red_config") else {
3399            return default;
3400        };
3401        let mut result = default;
3402        let mut latest_id: u64 = 0;
3403        manager.for_each_entity(|entity| {
3404            if let Some(row) = entity.data.as_row() {
3405                let entry_key = row.get_field("key").and_then(|v| match v {
3406                    crate::storage::schema::Value::Text(s) => Some(s.as_ref()),
3407                    _ => None,
3408                });
3409                if entry_key == Some(key) {
3410                    let id = entity.id.raw();
3411                    if id >= latest_id {
3412                        latest_id = id;
3413                        result = match row.get_field("value") {
3414                            Some(crate::storage::schema::Value::Integer(n)) => *n as u64,
3415                            Some(crate::storage::schema::Value::UnsignedInteger(n)) => *n,
3416                            Some(crate::storage::schema::Value::Text(s)) => {
3417                                s.parse::<u64>().unwrap_or(default)
3418                            }
3419                            _ => default,
3420                        };
3421                    }
3422                }
3423            }
3424            true
3425        });
3426        result
3427    }
3428
3429    pub(crate) fn config_f64(&self, key: &str, default: f64) -> f64 {
3430        if let Some(raw) = self.inner.env_config_overrides.get(key) {
3431            if let Ok(n) = raw.parse::<f64>() {
3432                return n;
3433            }
3434        }
3435        let store = self.inner.db.store();
3436        let Some(manager) = store.get_collection("red_config") else {
3437            return default;
3438        };
3439        let mut result = default;
3440        let mut latest_id: u64 = 0;
3441        manager.for_each_entity(|entity| {
3442            if let Some(row) = entity.data.as_row() {
3443                let entry_key = row.get_field("key").and_then(|v| match v {
3444                    crate::storage::schema::Value::Text(s) => Some(s.as_ref()),
3445                    _ => None,
3446                });
3447                if entry_key == Some(key) {
3448                    let id = entity.id.raw();
3449                    if id >= latest_id {
3450                        latest_id = id;
3451                        result = match row.get_field("value") {
3452                            Some(crate::storage::schema::Value::Float(n)) => *n,
3453                            Some(crate::storage::schema::Value::Integer(n)) => *n as f64,
3454                            Some(crate::storage::schema::Value::UnsignedInteger(n)) => *n as f64,
3455                            Some(crate::storage::schema::Value::Text(s)) => {
3456                                s.parse::<f64>().unwrap_or(default)
3457                            }
3458                            _ => default,
3459                        };
3460                    }
3461                }
3462            }
3463            true
3464        });
3465        result
3466    }
3467
3468    pub(crate) fn config_string(&self, key: &str, default: &str) -> String {
3469        if let Some(raw) = self.inner.env_config_overrides.get(key) {
3470            return raw.clone();
3471        }
3472        let store = self.inner.db.store();
3473        let Some(manager) = store.get_collection("red_config") else {
3474            return default.to_string();
3475        };
3476        let mut result = default.to_string();
3477        let mut latest_id: u64 = 0;
3478        manager.for_each_entity(|entity| {
3479            if let Some(row) = entity.data.as_row() {
3480                let entry_key = row.get_field("key").and_then(|v| match v {
3481                    crate::storage::schema::Value::Text(s) => Some(s.as_ref()),
3482                    _ => None,
3483                });
3484                if entry_key == Some(key) {
3485                    let id = entity.id.raw();
3486                    if id >= latest_id {
3487                        latest_id = id;
3488                        if let Some(crate::storage::schema::Value::Text(value)) =
3489                            row.get_field("value")
3490                        {
3491                            result = value.to_string();
3492                        }
3493                    }
3494                }
3495            }
3496            true
3497        });
3498        result
3499    }
3500
3501    /// Whether `SECRET('...')` literals should be encrypted with the
3502    /// vault AES key on INSERT. Default `true`.
3503    pub(crate) fn secret_auto_encrypt(&self) -> bool {
3504        self.config_bool("red.config.secret.auto_encrypt", true)
3505    }
3506
3507    /// Whether `Value::Secret` columns should be decrypted back to
3508    /// plaintext on SELECT when the vault is unsealed. Default `true`.
3509    /// Turning this off keeps secrets masked as `***` even while the
3510    /// vault is open — useful for audit trails or read-only exports.
3511    pub(crate) fn secret_auto_decrypt(&self) -> bool {
3512        self.config_bool("red.config.secret.auto_decrypt", true)
3513    }
3514
3515    /// Walk every record in `result` and swap `Value::Secret(bytes)`
3516    /// for the decrypted plaintext when the runtime has the vault
3517    /// AES key AND `red.config.secret.auto_decrypt = true`. If the
3518    /// key is missing, the vault is sealed, or auto_decrypt is off,
3519    /// secrets are left as `Value::Secret` which every formatter
3520    /// (Display, JSON) already masks as `***`.
3521    pub(crate) fn apply_secret_decryption(&self, result: &mut RuntimeQueryResult) {
3522        if !self.secret_auto_decrypt() {
3523            return;
3524        }
3525        let Some(key) = self.secret_aes_key() else {
3526            return;
3527        };
3528        for record in result.result.records.iter_mut() {
3529            for value in record.values_mut() {
3530                if let Value::Secret(ref bytes) = value {
3531                    if let Some(plain) =
3532                        super::impl_dml::decrypt_secret_payload(&key, bytes.as_slice())
3533                    {
3534                        if let Ok(text) = String::from_utf8(plain) {
3535                            *value = Value::text(text);
3536                        }
3537                    }
3538                }
3539            }
3540        }
3541    }
3542
3543    /// Emit a CDC change event and replicate to WAL buffer.
3544    /// Create a `MutationEngine` bound to this runtime.
3545    ///
3546    /// The engine is cheap to construct (no allocation) and should be
3547    /// dropped after `apply` returns. Use this from application-layer
3548    /// `create_row` / `create_rows_batch` instead of calling
3549    /// `bulk_insert` + `index_entity_insert` + `cdc_emit` separately.
3550    pub(crate) fn mutation_engine(&self) -> crate::runtime::mutation::MutationEngine<'_> {
3551        crate::runtime::mutation::MutationEngine::new(self)
3552    }
3553
3554    /// Public-mutation gate snapshot (PLAN.md W1).
3555    ///
3556    /// Surfaces that accept untrusted client requests (SQL DML/DDL,
3557    /// gRPC mutating RPCs, HTTP/native wire mutations, admin
3558    /// maintenance, serverless lifecycle) call `check_write` before
3559    /// dispatching to storage. Returns `RedDBError::ReadOnly` on any
3560    /// instance running as a replica or with `options.read_only =
3561    /// true`. The replica internal logical-WAL apply path reaches into
3562    /// the store directly and never calls this method, so legitimate
3563    /// replica catch-up still works.
3564    pub fn check_write(&self, kind: crate::runtime::write_gate::WriteKind) -> RedDBResult<()> {
3565        self.inner.write_gate.check(kind)
3566    }
3567
3568    /// Read-only handle to the gate, useful for transports that want
3569    /// to surface the policy in health/status output without taking on
3570    /// a dependency on the concrete enum.
3571    pub fn write_gate(&self) -> &crate::runtime::write_gate::WriteGate {
3572        &self.inner.write_gate
3573    }
3574
3575    /// Process lifecycle handle (PLAN.md Phase 1). Health probes,
3576    /// admin/shutdown, and signal handlers consult this single
3577    /// state machine.
3578    pub fn lifecycle(&self) -> &crate::runtime::lifecycle::Lifecycle {
3579        &self.inner.lifecycle
3580    }
3581
3582    /// Operator-imposed resource limits (PLAN.md Phase 4.1).
3583    pub fn resource_limits(&self) -> &crate::runtime::resource_limits::ResourceLimits {
3584        &self.inner.resource_limits
3585    }
3586
3587    /// Append-only audit log for admin mutations (PLAN.md Phase 6.5).
3588    pub fn audit_log(&self) -> &crate::runtime::audit_log::AuditLogger {
3589        &self.inner.audit_log
3590    }
3591
3592    /// Shared `Arc` to the audit logger — used by collaborators (the
3593    /// lease lifecycle, future request-context plumbing) that need to
3594    /// keep the logger alive past the runtime's stack frame.
3595    pub fn audit_log_arc(&self) -> Arc<crate::runtime::audit_log::AuditLogger> {
3596        Arc::clone(&self.inner.audit_log)
3597    }
3598
3599    pub(crate) fn emit_control_event(
3600        &self,
3601        kind: crate::runtime::control_events::EventKind,
3602        outcome: crate::runtime::control_events::Outcome,
3603        action: &'static str,
3604        resource: Option<String>,
3605        reason: Option<String>,
3606        extra_fields: Vec<(String, crate::runtime::control_events::Sensitivity)>,
3607    ) -> RedDBResult<()> {
3608        use crate::runtime::control_events::{
3609            ActorRef, ControlEvent, ControlEventCtx, ControlEventLedger, Sensitivity,
3610        };
3611
3612        let tenant = current_tenant();
3613        let principal = current_auth_identity();
3614        let actor_user = principal
3615            .as_ref()
3616            .map(|(principal, _)| UserId::from_parts(tenant.as_deref(), principal));
3617        let actor = actor_user
3618            .as_ref()
3619            .map(ActorRef::User)
3620            .unwrap_or(ActorRef::Anonymous);
3621        let ctx = ControlEventCtx {
3622            actor,
3623            scope: tenant
3624                .as_ref()
3625                .map(|scope| std::borrow::Cow::Borrowed(scope.as_str())),
3626            request_id: Some(std::borrow::Cow::Owned(format!(
3627                "conn-{}",
3628                current_connection_id()
3629            ))),
3630            trace_id: None,
3631        };
3632        let mut fields = std::collections::HashMap::new();
3633        fields.insert(
3634            "connection_id".to_string(),
3635            Sensitivity::raw(current_connection_id().to_string()),
3636        );
3637        if let Some((_, role)) = principal {
3638            fields.insert("actor_role".to_string(), Sensitivity::raw(role.as_str()));
3639        }
3640        for (key, value) in extra_fields {
3641            fields.insert(key, value);
3642        }
3643        let event = ControlEvent {
3644            kind,
3645            outcome,
3646            action: std::borrow::Cow::Borrowed(action),
3647            resource,
3648            reason,
3649            matched_policy_id: None,
3650            fields,
3651        };
3652        let ledger = self.inner.control_event_ledger.read();
3653        match ledger.emit(&ctx, event) {
3654            Ok(_) => Ok(()),
3655            Err(err) if self.inner.control_event_config.require_persistence() => {
3656                Err(RedDBError::Internal(err.to_string()))
3657            }
3658            Err(_) => Ok(()),
3659        }
3660    }
3661
3662    fn policy_mutation_control_ctx<'a>(
3663        &self,
3664        actor: &'a crate::auth::UserId,
3665        tenant: Option<&'a str>,
3666    ) -> crate::runtime::control_events::ControlEventCtx<'a> {
3667        crate::runtime::control_events::ControlEventCtx {
3668            actor: crate::runtime::control_events::ActorRef::User(actor),
3669            scope: tenant.map(std::borrow::Cow::Borrowed),
3670            request_id: Some(std::borrow::Cow::Owned(format!(
3671                "conn-{}",
3672                current_connection_id()
3673            ))),
3674            trace_id: None,
3675        }
3676    }
3677
3678    fn emit_query_audit(
3679        &self,
3680        query: &str,
3681        plan: &QueryAuditPlan,
3682        duration_ms: u64,
3683        result: &RuntimeQueryResult,
3684    ) {
3685        if !self.inner.query_audit.has_rules() {
3686            return;
3687        }
3688        let actor = current_auth_identity().map(|(principal, _)| principal);
3689        let tenant = current_tenant();
3690        let row_count = if result.statement_type == "select" {
3691            result.result.records.len() as u64
3692        } else {
3693            result.affected_rows
3694        };
3695        self.inner
3696            .query_audit
3697            .emit(crate::runtime::query_audit::QueryAuditEvent {
3698                actor,
3699                tenant,
3700                statement_kind: plan.statement_kind,
3701                touched_collections: plan.collections.clone(),
3702                duration_ms,
3703                row_count,
3704                request_id: Some(crate::crypto::uuid::Uuid::new_v7().to_string()),
3705                query_hash: Some(blake3::hash(query.as_bytes()).to_hex().to_string()),
3706            });
3707    }
3708
3709    /// Shared queue telemetry counters (delivered/acked/nacked).
3710    pub(crate) fn queue_telemetry(
3711        &self,
3712    ) -> &crate::runtime::queue_telemetry::QueueTelemetryCounters {
3713        &self.inner.queue_telemetry
3714    }
3715
3716    /// Snapshots of the queue telemetry counters in label-deterministic
3717    /// order for `/metrics` rendering and the integration test.
3718    pub fn queue_telemetry_snapshot(
3719        &self,
3720    ) -> crate::runtime::queue_telemetry::QueueTelemetrySnapshot {
3721        crate::runtime::queue_telemetry::QueueTelemetrySnapshot {
3722            delivered: self.inner.queue_telemetry.delivered_snapshot(),
3723            acked: self.inner.queue_telemetry.acked_snapshot(),
3724            nacked: self.inner.queue_telemetry.nacked_snapshot(),
3725            wait_started: self.inner.queue_telemetry.wait_started_snapshot(),
3726            wait_woken: self.inner.queue_telemetry.wait_woken_snapshot(),
3727            wait_timed_out: self.inner.queue_telemetry.wait_timed_out_snapshot(),
3728            wait_cancelled: self.inner.queue_telemetry.wait_cancelled_snapshot(),
3729            wait_duration: self.inner.queue_telemetry.wait_duration_snapshot(),
3730        }
3731    }
3732
3733    /// Snapshots of Concurrent claim counters in label-deterministic order.
3734    pub fn claim_telemetry_snapshot(&self) -> crate::runtime::ClaimTelemetrySnapshot {
3735        self.inner.claim_telemetry.snapshot()
3736    }
3737
3738    /// Per-`kind` query latency histograms for `/metrics` (only kinds with
3739    /// a real sample are present — empty kinds are absent, not zero-filled).
3740    pub fn query_latency_snapshot(
3741        &self,
3742    ) -> Vec<crate::runtime::query_latency_telemetry::QueryLatencyHistogram> {
3743        self.inner.query_latency_telemetry.snapshot()
3744    }
3745
3746    /// Cross-kind query latency rollup for `/cluster/status` and the
3747    /// red-ui percentile panels. `count == 0` until a real sample exists.
3748    pub fn query_latency_rollup(
3749        &self,
3750    ) -> crate::runtime::query_latency_telemetry::QueryLatencyHistogram {
3751        self.inner.query_latency_telemetry.rollup()
3752    }
3753
3754    /// Issue #1244 — take a fresh node CPU/RAM occupancy reading for
3755    /// `/cluster/status`. CPU utilisation is measured over the interval
3756    /// since the previous call (the first call only establishes a baseline
3757    /// and reports `NotSampled`). See `occupancy_sampler` for overhead.
3758    pub fn sample_occupancy(&self) -> crate::runtime::occupancy_sampler::OccupancySample {
3759        self.inner.occupancy_sampler.sample()
3760    }
3761
3762    /// Issue #1245 — point-in-time node load snapshot (active queries +
3763    /// connect/disconnect churn). Feeds `/metrics`, `/cluster/status`, and
3764    /// the red-ui load panels.
3765    pub fn node_load_snapshot(&self) -> crate::runtime::node_load_telemetry::NodeLoadSnapshot {
3766        self.inner.node_load_telemetry.snapshot()
3767    }
3768
3769    /// Issue #742 — consumer presence registry. Heartbeats land here
3770    /// from `QUEUE READ` (and, in a follow-up slice, an explicit
3771    /// `QUEUE HEARTBEAT` command); Red UI and `red.queue_consumers`
3772    /// read snapshots through `queue_consumer_presence_snapshot`.
3773    pub(crate) fn queue_presence(
3774        &self,
3775    ) -> &std::sync::Arc<crate::storage::queue::presence::ConsumerPresenceRegistry> {
3776        &self.inner.queue_presence
3777    }
3778
3779    /// Issue #742 — point-in-time presence snapshot, classifying each
3780    /// `(queue, group, consumer)` as active/stale/expired against the
3781    /// supplied TTL. Wall-clock is read once here so the lifecycle
3782    /// flags inside the snapshot are internally consistent.
3783    pub fn queue_consumer_presence_snapshot(
3784        &self,
3785        ttl_ms: u64,
3786    ) -> Vec<crate::storage::queue::presence::ConsumerPresence> {
3787        let now_ns = std::time::SystemTime::now()
3788            .duration_since(std::time::UNIX_EPOCH)
3789            .map(|d| d.as_nanos() as u64)
3790            .unwrap_or(0);
3791        self.inner.queue_presence.snapshot(now_ns, ttl_ms)
3792    }
3793
3794    /// Issue #742 — active-consumer count per `(queue, group)` for the
3795    /// queue-metadata surface. Stale/expired entries are excluded by
3796    /// definition; they are still visible in the per-row snapshot.
3797    pub fn queue_active_consumer_counts(
3798        &self,
3799        ttl_ms: u64,
3800    ) -> std::collections::HashMap<(String, String), u32> {
3801        let now_ns = std::time::SystemTime::now()
3802            .duration_since(std::time::UNIX_EPOCH)
3803            .map(|d| d.as_nanos() as u64)
3804            .unwrap_or(0);
3805        self.inner
3806            .queue_presence
3807            .count_active_by_group(now_ns, ttl_ms)
3808    }
3809
3810    /// Issue #743 — vector + TurboQuant introspection registry. Engine
3811    /// publish points (collection create, artifact build start /
3812    /// finish, fallback toggle, drop) update this; Red UI and
3813    /// `red.*` vector virtual tables read snapshots through
3814    /// `vector_introspection_snapshot` / `vector_introspection_get`.
3815    pub(crate) fn vector_introspection_registry(
3816        &self,
3817    ) -> &std::sync::Arc<crate::storage::vector::introspection::VectorIntrospectionRegistry> {
3818        &self.inner.vector_introspection
3819    }
3820
3821    /// Issue #743 — full snapshot of every tracked vector collection's
3822    /// `(VectorMetadata, ArtifactMetadata)`. Deterministically ordered
3823    /// by collection name so Red UI tables and tests both see a
3824    /// stable shape.
3825    pub fn vector_introspection_snapshot(
3826        &self,
3827    ) -> Vec<crate::storage::vector::introspection::VectorIntrospection> {
3828        self.inner.vector_introspection.snapshot()
3829    }
3830
3831    /// Issue #743 — single-collection lookup, for the per-collection
3832    /// metadata endpoint Red UI hits when an operator opens one
3833    /// vector's toolbar.
3834    pub fn vector_introspection_get(
3835        &self,
3836        collection: &str,
3837    ) -> Option<crate::storage::vector::introspection::VectorIntrospection> {
3838        self.inner.vector_introspection.get(collection)
3839    }
3840
3841    /// Issue #1238 — ADR 0060 read-model accessor for slow-query telemetry.
3842    ///
3843    /// Returns a reference to the bounded ring store so HTTP handlers and
3844    /// the red-ui read model can call `store.read(filter)` without
3845    /// touching `red-slow.log` directly.
3846    pub fn slow_query_store(&self) -> &Arc<crate::telemetry::slow_query_store::SlowQueryStore> {
3847        &self.inner.slow_query_store
3848    }
3849
3850    /// Slice 10 of issue #527 — render-time scan of pending entries
3851    /// per (queue, group) for the `queue_pending_gauge` exposition.
3852    /// Walks `red_queue_meta` live so the gauge cannot drift from
3853    /// the source of truth.
3854    pub fn queue_pending_counts(&self) -> Vec<((String, String), u64)> {
3855        let store = self.inner.db.store();
3856        crate::runtime::impl_queue::pending_counts_by_group(store.as_ref())
3857            .into_iter()
3858            .collect()
3859    }
3860
3861    /// Shared `Arc` to the write gate. Same rationale as
3862    /// `audit_log_arc`: collaborators (lease lifecycle, refresh
3863    /// thread) need a clone-cheap handle they can move into a
3864    /// background thread.
3865    pub fn write_gate_arc(&self) -> Arc<crate::runtime::write_gate::WriteGate> {
3866        Arc::clone(&self.inner.write_gate)
3867    }
3868
3869    /// Serverless writer-lease state machine. `None` when the operator
3870    /// did not opt into lease fencing (`RED_LEASE_REQUIRED` unset).
3871    pub fn lease_lifecycle(&self) -> Option<&Arc<crate::runtime::lease_lifecycle::LeaseLifecycle>> {
3872        self.inner.lease_lifecycle.get()
3873    }
3874
3875    /// Install the lease lifecycle. Idempotent; subsequent calls
3876    /// return the previously stored value untouched.
3877    pub fn set_lease_lifecycle(
3878        &self,
3879        lifecycle: Arc<crate::runtime::lease_lifecycle::LeaseLifecycle>,
3880    ) -> Result<(), Arc<crate::runtime::lease_lifecycle::LeaseLifecycle>> {
3881        self.inner.lease_lifecycle.set(lifecycle)
3882    }
3883
3884    /// Reject the call when the requested batch size exceeds
3885    /// `RED_MAX_BATCH_SIZE`. Returns `RedDBError::QuotaExceeded`
3886    /// shaped so the HTTP layer can map it to 413 Payload Too
3887    /// Large (PLAN.md Phase 4.1).
3888    pub fn check_batch_size(&self, requested: usize) -> RedDBResult<()> {
3889        if self.inner.resource_limits.batch_size_exceeded(requested) {
3890            let max = self.inner.resource_limits.max_batch_size.unwrap_or(0);
3891            return Err(RedDBError::QuotaExceeded(format!(
3892                "max_batch_size:{requested}:{max}"
3893            )));
3894        }
3895        Ok(())
3896    }
3897
3898    /// Reject the call when the local DB file exceeds
3899    /// `RED_MAX_DB_SIZE_BYTES`. Reads file metadata once per call —
3900    /// the cost is a single `stat()` syscall, negligible against the
3901    /// I/O the caller is about to do. Returns `QuotaExceeded` shaped
3902    /// for HTTP 507 Insufficient Storage.
3903    pub fn check_db_size(&self) -> RedDBResult<()> {
3904        let Some(limit) = self.inner.resource_limits.max_db_size_bytes else {
3905            return Ok(());
3906        };
3907        if limit == 0 {
3908            return Ok(());
3909        }
3910        let Some(path) = self.inner.db.path() else {
3911            return Ok(());
3912        };
3913        let current = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
3914        if current > limit {
3915            return Err(RedDBError::QuotaExceeded(format!(
3916                "max_db_size_bytes:{current}:{limit}"
3917            )));
3918        }
3919        Ok(())
3920    }
3921
3922    /// Graceful shutdown coordinator (PLAN.md Phase 1.1).
3923    ///
3924    /// Steps, in order, all idempotent across re-entrant calls:
3925    ///   1. Move lifecycle into `ShuttingDown` (concurrent callers
3926    ///      observe `Stopped` after first finishes).
3927    ///   2. Flush WAL + run final checkpoint via `db.flush()` so
3928    ///      every acked write is durable on disk.
3929    ///   3. If `backup_on_shutdown == true` and a remote backend is
3930    ///      configured, run a synchronous `trigger_backup()` so the
3931    ///      remote head reflects the final state.
3932    ///   4. Stamp the report and move to `Stopped`. Subsequent calls
3933    ///      return the cached report without re-running anything.
3934    ///
3935    /// On any error, the runtime is still marked `Stopped` so the
3936    /// process can exit; the caller logs the error context but does
3937    /// not retry the same shutdown — the operator can inspect the
3938    /// report fields to see which step failed.
3939    pub fn graceful_shutdown(
3940        &self,
3941        backup_on_shutdown: bool,
3942    ) -> RedDBResult<crate::runtime::lifecycle::ShutdownReport> {
3943        if !self.inner.lifecycle.begin_shutdown() {
3944            // Someone else already shut down (or is in flight). Return
3945            // the cached report so the HTTP caller and SIGTERM handler
3946            // get the same idempotent answer.
3947            return Ok(self.inner.lifecycle.shutdown_report().unwrap_or_default());
3948        }
3949
3950        let started_ms = std::time::SystemTime::now()
3951            .duration_since(std::time::UNIX_EPOCH)
3952            .map(|d| d.as_millis() as u64)
3953            .unwrap_or(0);
3954        let mut report = crate::runtime::lifecycle::ShutdownReport {
3955            started_at_ms: started_ms,
3956            ..Default::default()
3957        };
3958
3959        // Flush WAL + run any pending checkpoint. Local fsync is
3960        // unconditional — even a lease-lost replica needs its WAL on
3961        // disk before exit so a future restore has the latest tail.
3962        // The remote upload is gated separately so a lost-lease writer
3963        // doesn't clobber the new holder's state on its way out.
3964        let flush_res = self.inner.db.flush_local_only();
3965        report.flushed_wal = flush_res.is_ok();
3966        report.final_checkpoint = flush_res.is_ok();
3967        if let Err(err) = &flush_res {
3968            tracing::error!(
3969                target: "reddb::lifecycle",
3970                error = %err,
3971                "graceful_shutdown: local flush failed"
3972            );
3973        } else if let Err(lease_err) =
3974            self.assert_remote_write_allowed("shutdown/checkpoint_upload")
3975        {
3976            tracing::warn!(
3977                target: "reddb::serverless::lease",
3978                error = %lease_err,
3979                "graceful_shutdown: remote upload skipped — lease not held"
3980            );
3981        } else if let Err(err) = self.inner.db.upload_to_remote_backend() {
3982            tracing::error!(
3983                target: "reddb::lifecycle",
3984                error = %err,
3985                "graceful_shutdown: remote upload failed"
3986            );
3987        }
3988
3989        // Optional final backup. Skipped silently when no remote
3990        // backend is configured — `trigger_backup()` returns Err
3991        // anyway in that case, but logging it as a shutdown failure
3992        // would be misleading on a standalone (no-backend) runtime.
3993        if backup_on_shutdown && self.inner.db.remote_backend.is_some() {
3994            // The trigger_backup gate now reads `WriteKind::Backup`,
3995            // which a replica/read_only instance refuses. That's
3996            // intentional — replicas don't drive backups; only the
3997            // primary does. We still want shutdown to flush its WAL
3998            // even if the backup branch is gated off.
3999            match self.trigger_backup() {
4000                Ok(result) => {
4001                    report.backup_uploaded = result.uploaded;
4002                }
4003                Err(err) => {
4004                    tracing::warn!(
4005                        target: "reddb::lifecycle",
4006                        error = %err,
4007                        "graceful_shutdown: final backup skipped"
4008                    );
4009                }
4010            }
4011        }
4012
4013        let completed_ms = std::time::SystemTime::now()
4014            .duration_since(std::time::UNIX_EPOCH)
4015            .map(|d| d.as_millis() as u64)
4016            .unwrap_or(started_ms);
4017        report.completed_at_ms = completed_ms;
4018        report.duration_ms = completed_ms.saturating_sub(started_ms);
4019
4020        self.inner.lifecycle.finish_shutdown(report.clone());
4021        Ok(report)
4022    }
4023
4024    /// PLAN.md Phase 4.4 — per-caller quota bucket. Always
4025    /// returned; `is_configured()` lets callers short-circuit.
4026    pub fn quota_bucket(&self) -> &crate::runtime::quota_bucket::QuotaBucket {
4027        &self.inner.quota_bucket
4028    }
4029
4030    /// PLAN.md Phase 6.3 — whether at-rest encryption is configured.
4031    /// Reads `RED_ENCRYPTION_KEY` / `RED_ENCRYPTION_KEY_FILE` lazily;
4032    /// returns `("enabled", None)` when a key is loadable, `("error", Some(msg))`
4033    /// when the operator set the env but it doesn't parse, and
4034    /// `("disabled", None)` when no key is configured. The pager
4035    /// hookup is deferred — this accessor surfaces the operator's
4036    /// intent for /admin/status without yet using the key in writes.
4037    pub fn encryption_at_rest_status(&self) -> (&'static str, Option<String>) {
4038        match crate::crypto::page_encryption::key_from_env() {
4039            Ok(Some(_)) => ("enabled", None),
4040            Ok(None) => ("disabled", None),
4041            Err(err) => ("error", Some(err)),
4042        }
4043    }
4044
4045    /// PLAN.md Phase 11.5 — current replica apply health label
4046    /// (`ok`, `gap`, `divergence`, `apply_error`, `connecting`,
4047    /// `stalled_gap`). Read from the persisted `red.replication.state`
4048    /// config key updated by the replica loop. Returns `None` on
4049    /// non-replica instances or when no apply has run yet.
4050    pub fn replica_apply_health(&self) -> Option<String> {
4051        let state = self.config_string("red.replication.state", "");
4052        if state.is_empty() {
4053            None
4054        } else {
4055            Some(state)
4056        }
4057    }
4058
4059    pub fn acquire(&self) -> RedDBResult<RuntimeConnection> {
4060        let mut pool = self
4061            .inner
4062            .pool
4063            .lock()
4064            .map_err(|e| RedDBError::Internal(format!("connection pool lock poisoned: {e}")))?;
4065        if pool.active >= self.inner.pool_config.max_connections {
4066            return Err(RedDBError::Internal(
4067                "connection pool exhausted".to_string(),
4068            ));
4069        }
4070
4071        let id = if let Some(id) = pool.idle.pop() {
4072            id
4073        } else {
4074            let id = pool.next_id;
4075            pool.next_id += 1;
4076            id
4077        };
4078        pool.active += 1;
4079        pool.total_checkouts += 1;
4080        drop(pool);
4081
4082        // Issue #1245 — record the connection acquisition after releasing
4083        // the pool lock so the lock hold time is unchanged.
4084        self.inner.node_load_telemetry.record_connect();
4085
4086        Ok(RuntimeConnection {
4087            id,
4088            inner: Arc::clone(&self.inner),
4089        })
4090    }
4091
4092    pub fn checkpoint(&self) -> RedDBResult<()> {
4093        // Local fsync always allowed — losing the lease shouldn't
4094        // prevent us from durably persisting what's already in memory.
4095        // The remote upload is the side-effect that risks clobbering a
4096        // peer's state, so it's behind the lease gate.
4097        self.inner.db.flush_local_only().map_err(|err| {
4098            // Issue #205 — local flush failure is a CheckpointFailed
4099            // operator-grade event. The local-flush path also covers
4100            // the WAL fsync we depend on, so a failure here doubles as
4101            // the WalFsyncFailed signal for the runtime entry point.
4102            let msg = err.to_string();
4103            crate::telemetry::operator_event::OperatorEvent::CheckpointFailed {
4104                lsn: 0,
4105                error: msg.clone(),
4106            }
4107            .emit_global();
4108            crate::telemetry::operator_event::OperatorEvent::WalFsyncFailed {
4109                path: "<flush_local_only>".to_string(),
4110                error: msg.clone(),
4111            }
4112            .emit_global();
4113            RedDBError::Engine(msg)
4114        })?;
4115        if let Err(err) = self.assert_remote_write_allowed("checkpoint") {
4116            tracing::warn!(
4117                target: "reddb::serverless::lease",
4118                error = %err,
4119                "checkpoint: skipping remote upload — lease not held"
4120            );
4121            return Ok(());
4122        }
4123        self.inner
4124            .db
4125            .upload_to_remote_backend()
4126            .map_err(|err| RedDBError::Engine(err.to_string()))
4127    }
4128
4129    /// Guard remote-mutating operations on the writer lease.
4130    /// Returns `Ok(())` when no remote backend is configured (the
4131    /// lease is irrelevant) or the lease state is `NotRequired` /
4132    /// `Held`. Returns `RedDBError::ReadOnly` when the lease is
4133    /// `NotHeld`, with an audit-friendly action label so the caller
4134    /// can record the rejection.
4135    pub(crate) fn assert_remote_write_allowed(&self, action: &str) -> RedDBResult<()> {
4136        if self.inner.db.remote_backend.is_none() {
4137            return Ok(());
4138        }
4139        match self.inner.write_gate.lease_state() {
4140            crate::runtime::write_gate::LeaseGateState::NotHeld => {
4141                self.inner.audit_log.record(
4142                    action,
4143                    "system",
4144                    "remote_backend",
4145                    "err: writer lease not held",
4146                    crate::json::Value::Null,
4147                );
4148                Err(RedDBError::ReadOnly(format!(
4149                    "writer lease not held — {action} blocked (serverless fence)"
4150                )))
4151            }
4152            _ => Ok(()),
4153        }
4154    }
4155
4156    pub fn run_maintenance(&self) -> RedDBResult<()> {
4157        self.inner
4158            .db
4159            .run_maintenance()
4160            .map_err(|err| RedDBError::Internal(err.to_string()))
4161    }
4162
4163    pub fn scan_collection(
4164        &self,
4165        collection: &str,
4166        cursor: Option<ScanCursor>,
4167        limit: usize,
4168    ) -> RedDBResult<ScanPage> {
4169        let store = self.inner.db.store();
4170        let manager = store
4171            .get_collection(collection)
4172            .ok_or_else(|| RedDBError::NotFound(collection.to_string()))?;
4173
4174        let mut entities = manager.query_all(|_| true);
4175        entities.sort_by_key(|entity| entity.id.raw());
4176
4177        let offset = cursor.map(|cursor| cursor.offset).unwrap_or(0);
4178        let total = entities.len();
4179        let end = total.min(offset.saturating_add(limit.max(1)));
4180        let items = if offset >= total {
4181            Vec::new()
4182        } else {
4183            entities[offset..end].to_vec()
4184        };
4185        let next = (end < total).then_some(ScanCursor { offset: end });
4186
4187        Ok(ScanPage {
4188            collection: collection.to_string(),
4189            items,
4190            next,
4191            total,
4192        })
4193    }
4194
4195    pub fn catalog(&self) -> CatalogModelSnapshot {
4196        self.inner.db.catalog_model_snapshot()
4197    }
4198
4199    pub fn catalog_consistency_report(&self) -> crate::catalog::CatalogConsistencyReport {
4200        self.inner.db.catalog_consistency_report()
4201    }
4202
4203    pub fn catalog_attention_summary(&self) -> CatalogAttentionSummary {
4204        crate::catalog::attention_summary(&self.catalog())
4205    }
4206
4207    pub fn collection_attention(&self) -> Vec<CollectionDescriptor> {
4208        crate::catalog::collection_attention(&self.catalog())
4209    }
4210
4211    pub fn index_attention(&self) -> Vec<CatalogIndexStatus> {
4212        crate::catalog::index_attention(&self.catalog())
4213    }
4214
4215    pub fn graph_projection_attention(&self) -> Vec<CatalogGraphProjectionStatus> {
4216        crate::catalog::graph_projection_attention(&self.catalog())
4217    }
4218
4219    pub fn analytics_job_attention(&self) -> Vec<CatalogAnalyticsJobStatus> {
4220        crate::catalog::analytics_job_attention(&self.catalog())
4221    }
4222
4223    pub fn stats(&self) -> RuntimeStats {
4224        let pool = runtime_pool_lock(self);
4225        RuntimeStats {
4226            active_connections: pool.active,
4227            idle_connections: pool.idle.len(),
4228            total_checkouts: pool.total_checkouts,
4229            paged_mode: self.inner.db.is_paged(),
4230            started_at_unix_ms: self.inner.started_at_unix_ms,
4231            store: self.inner.db.stats(),
4232            system: SystemInfo::collect(),
4233            result_blob_cache: self.inner.result_blob_cache.stats(),
4234            kv: self.inner.kv_stats.snapshot(),
4235            metrics_ingest: self.inner.metrics_ingest_stats.snapshot(),
4236        }
4237    }
4238
4239    pub(crate) fn record_metrics_ingest(
4240        &self,
4241        accepted_samples: u64,
4242        accepted_series: u64,
4243        rejected_samples: u64,
4244        rejected_series: u64,
4245    ) {
4246        self.inner.metrics_ingest_stats.record(
4247            accepted_samples,
4248            accepted_series,
4249            rejected_samples,
4250            rejected_series,
4251        );
4252    }
4253
4254    pub(crate) fn record_metrics_cardinality_budget_rejections(&self, rejected_series: u64) {
4255        self.inner
4256            .metrics_ingest_stats
4257            .record_cardinality_budget_rejections(rejected_series);
4258    }
4259
4260    pub(crate) fn record_metrics_tenant_activity(
4261        &self,
4262        tenant: &str,
4263        namespace: &str,
4264        operation: &str,
4265    ) {
4266        self.inner
4267            .metrics_tenant_activity_stats
4268            .record(tenant, namespace, operation);
4269    }
4270
4271    pub(crate) fn metrics_tenant_activity_snapshot(
4272        &self,
4273    ) -> Vec<crate::runtime::MetricsTenantActivityStats> {
4274        self.inner.metrics_tenant_activity_stats.snapshot()
4275    }
4276
4277    /// Execute a query under a typed scope override without embedding
4278    /// the tenant / user / role values into the SQL string. Use this
4279    /// from transport middleware (HTTP / gRPC / worker loops) where the
4280    /// scope is resolved from auth claims and the SQL is a parameterised
4281    /// template — avoids the string-concat injection risk of building
4282    /// `WITHIN TENANT '<id>' …` manually, and is drop-in compatible with
4283    /// prepared statements that didn't know about tenancy.
4284    ///
4285    /// Precedence matches the `WITHIN` clause: the passed `scope`
4286    /// overrides `SET LOCAL TENANT`, which overrides `SET TENANT`.
4287    /// The override is pushed on the thread-local scope stack for the
4288    /// duration of the call and popped on return — pool-shared
4289    /// connections cannot leak it across requests.
4290    pub fn execute_query_with_scope(
4291        &self,
4292        query: &str,
4293        scope: crate::runtime::within_clause::ScopeOverride,
4294    ) -> RedDBResult<RuntimeQueryResult> {
4295        if scope.is_empty() {
4296            return self.execute_query(query);
4297        }
4298        let _scope_guard = ScopeOverrideGuard::install(scope);
4299        self.execute_query(query)
4300    }
4301
4302    /// Issue #205 — single lifecycle exit for slow-query logging.
4303    ///
4304    /// `execute_query_inner` does the real work; this wrapper times it
4305    /// and, if elapsed exceeds the configured threshold, hands the
4306    /// triple `(QueryKind, elapsed_ms, sql_redacted, scope)` to the
4307    /// SlowQueryLogger. The threshold + sample_pct were captured at
4308    /// SlowQueryLogger construction (runtime startup), so the per-call
4309    /// cost on below-threshold paths is one relaxed atomic load.
4310    pub fn execute_query(&self, query: &str) -> RedDBResult<RuntimeQueryResult> {
4311        let started = std::time::Instant::now();
4312        self.inner.node_load_telemetry.query_start();
4313        let result = self.execute_query_inner(query);
4314        self.finish_query_lifecycle(query, started, result)
4315    }
4316
4317    /// Execute a SQL statement with already-decoded positional bind
4318    /// parameters. Transports should call this instead of parsing +
4319    /// binding on their side and then reaching for `execute_query_expr`:
4320    /// this entry keeps parameterized statements inside the same
4321    /// statement lifecycle as textual SQL (snapshot guard, config/secret
4322    /// guards, coarse auth, intent locks, slow-query logging, integrity
4323    /// tombstone filtering, and causal bookmarks).
4324    pub fn execute_query_with_params(
4325        &self,
4326        query: &str,
4327        params: &[Value],
4328    ) -> RedDBResult<RuntimeQueryResult> {
4329        if params.is_empty() {
4330            return self.execute_query(query);
4331        }
4332        let started = std::time::Instant::now();
4333        self.inner.node_load_telemetry.query_start();
4334        let result = self.execute_query_with_params_inner(query, params);
4335        self.finish_query_lifecycle(query, started, result)
4336    }
4337
4338    fn finish_query_lifecycle(
4339        &self,
4340        query: &str,
4341        started: std::time::Instant,
4342        mut result: RedDBResult<RuntimeQueryResult>,
4343    ) -> RedDBResult<RuntimeQueryResult> {
4344        // Issue #765 / S6 — filter integrity-tombstoned rows out of SELECT
4345        // results before they reach any consumer. Fast no-op (one relaxed
4346        // atomic load) unless an input-stream digest mismatch has tombstoned
4347        // a RID range on this store.
4348        if let Ok(ref mut query_result) = result {
4349            if query_result.statement_type == "select" {
4350                self.filter_integrity_tombstoned(&mut query_result.result);
4351            }
4352        }
4353        let elapsed_ms = started.elapsed().as_millis() as u64;
4354
4355        // Build EffectiveScope from the same thread-locals frame-build
4356        // consults — keeps the slow-log row consistent with the audit /
4357        // RLS view of "this statement". `ai_scope()` is the canonical
4358        // builder.
4359        let scope = self.ai_scope();
4360        let kind = match result
4361            .as_ref()
4362            .map(|r| r.statement_type)
4363            .unwrap_or("select")
4364        {
4365            "select" => crate::telemetry::slow_query_logger::QueryKind::Select,
4366            "insert" => crate::telemetry::slow_query_logger::QueryKind::Insert,
4367            "update" => crate::telemetry::slow_query_logger::QueryKind::Update,
4368            "delete" => crate::telemetry::slow_query_logger::QueryKind::Delete,
4369            _ => crate::telemetry::slow_query_logger::QueryKind::Internal,
4370        };
4371        // SQL redaction: pass the raw query through. The slow-query
4372        // logger writes structured JSON so embedded literals stay
4373        // escape-safe at the JSON boundary (proven by
4374        // `adversarial_sql_is_escape_safe` in slow_query_logger.rs).
4375        // PII redaction (e.g. literal masking) is a follow-up.
4376        self.inner
4377            .slow_query_logger
4378            .record(kind, elapsed_ms, query.to_string(), &scope);
4379
4380        // Issue #1241 — record latency into the bounded per-`kind`
4381        // histogram substrate (always, not only above the slow-query
4382        // threshold). `started.elapsed()` is re-read here for sub-ms
4383        // resolution; the cost is one `Instant::now` plus a handful of
4384        // relaxed atomic adds (see `query_latency_telemetry` docs).
4385        self.inner
4386            .query_latency_telemetry
4387            .observe(kind, started.elapsed().as_secs_f64());
4388
4389        // Issue #1245 — decrement the active-query gauge. One relaxed
4390        // atomic sub; the matching increment happened at execute_query /
4391        // execute_query_with_params entry.
4392        self.inner.node_load_telemetry.query_finish();
4393
4394        if let Ok(ref mut query_result) = result {
4395            if matches!(query_result.statement_type, "insert" | "update" | "delete") {
4396                let bookmark = crate::replication::CausalBookmark::new(
4397                    self.current_replication_term(),
4398                    self.cdc_current_lsn(),
4399                );
4400                query_result.bookmark = Some(bookmark.encode());
4401            }
4402        }
4403
4404        result
4405    }
4406
4407    fn execute_query_with_params_inner(
4408        &self,
4409        query: &str,
4410        params: &[Value],
4411    ) -> RedDBResult<RuntimeQueryResult> {
4412        let parsed = parse_multi(query).map_err(|err| RedDBError::Query(err.to_string()))?;
4413        let bound = crate::storage::query::user_params::bind(&parsed, params).map_err(|err| {
4414            RedDBError::Validation {
4415                message: err.to_string(),
4416                validation: crate::json!({
4417                    "code": "INVALID_PARAMS",
4418                    "surface": "query.params",
4419                }),
4420            }
4421        })?;
4422        self.execute_bound_query_expr_in_frame(query, bound)
4423    }
4424
4425    fn execute_bound_query_expr_in_frame(
4426        &self,
4427        query: &str,
4428        expr: QueryExpr,
4429    ) -> RedDBResult<RuntimeQueryResult> {
4430        let rewritten_query = super::red_schema::rewrite_virtual_names(query);
4431        let execution_query = rewritten_query.as_deref().unwrap_or(query);
4432        let frame = super::statement_frame::StatementExecutionFrame::build(self, execution_query)?;
4433        let _frame_guards = frame.install(self);
4434        let _log_span = crate::telemetry::span::query_span(query).entered();
4435
4436        let expr = self.rewrite_view_refs(expr);
4437        let mode = detect_mode(execution_query);
4438        let control_event_specs = query_control_event_specs(&expr);
4439        let _lock_guard = match frame.prepare_dispatch(self, &expr) {
4440            Ok(guard) => guard,
4441            Err(err) => {
4442                let outcome = control_event_outcome_for_error(&err);
4443                for spec in &control_event_specs {
4444                    self.emit_control_event(
4445                        spec.kind,
4446                        outcome,
4447                        spec.action,
4448                        spec.resource.clone(),
4449                        Some(err.to_string()),
4450                        spec.fields.clone(),
4451                    )?;
4452                }
4453                return Err(err);
4454            }
4455        };
4456
4457        let mut result = self.dispatch_expr(expr, query, mode)?;
4458        if result.statement_type == "select" {
4459            self.apply_secret_decryption(&mut result);
4460        }
4461        Ok(result)
4462    }
4463
4464    pub fn causal_session(&self) -> crate::runtime::CausalSession {
4465        crate::runtime::CausalSession {
4466            runtime: self.clone(),
4467            bookmark: None,
4468            wait_timeout: std::time::Duration::from_secs(5),
4469        }
4470    }
4471
4472    pub fn wait_for_bookmark(
4473        &self,
4474        bookmark: &crate::replication::CausalBookmark,
4475        timeout: std::time::Duration,
4476    ) -> RedDBResult<()> {
4477        let deadline = std::time::Instant::now() + timeout;
4478        loop {
4479            let applied_lsn = self.local_contiguous_applied_lsn();
4480            if applied_lsn >= bookmark.commit_lsn() {
4481                return Ok(());
4482            }
4483            let now = std::time::Instant::now();
4484            if now >= deadline {
4485                return Err(RedDBError::InvalidOperation(format!(
4486                    "timed out waiting for causal bookmark lsn {}; applied={}",
4487                    bookmark.commit_lsn(),
4488                    applied_lsn
4489                )));
4490            }
4491            let remaining = deadline.saturating_duration_since(now);
4492            std::thread::sleep(remaining.min(std::time::Duration::from_millis(5)));
4493        }
4494    }
4495
4496    fn local_contiguous_applied_lsn(&self) -> u64 {
4497        match self.inner.db.options().replication.role {
4498            crate::replication::ReplicationRole::Replica { .. } => {
4499                self.config_u64("red.replication.last_applied_lsn", 0)
4500            }
4501            _ => self.cdc_current_lsn(),
4502        }
4503    }
4504
4505    #[inline(never)]
4506    fn execute_query_inner(&self, query: &str) -> RedDBResult<RuntimeQueryResult> {
4507        // ── ULTRA-TURBO: autocommit `SELECT * FROM t WHERE _entity_id = N` ──
4508        //
4509        // Moved above every boot-cost the normal path pays (WITHIN
4510        // strip, SET LOCAL parse, tx_local_tenants read, snapshot
4511        // guard, tracing span, tx_contexts read) because the bench's
4512        // `select_point` scenario was observed at 28× vs PostgreSQL —
4513        // the dominant cost wasn't the entity fetch but the ceremony
4514        // before it. Only fires when there's no ambient transaction
4515        // context or WITHIN override, so the snapshot install we skip
4516        // truly is a no-op for this query.
4517        if !has_scope_override_active()
4518            && !query.trim_start().starts_with("WITHIN")
4519            && !query.trim_start().starts_with("within")
4520            && !self.inner.query_audit.has_rules()
4521            && !self
4522                .inner
4523                .tx_contexts
4524                .read()
4525                .contains_key(&current_connection_id())
4526        {
4527            if let Some(result) = self.try_fast_entity_lookup(query) {
4528                return result;
4529            }
4530        }
4531
4532        // `WITHIN TENANT '<id>' [USER '<u>'] [AS ROLE '<r>'] <stmt>` —
4533        // strip the prefix, push a stack-scoped override, recurse on
4534        // the inner statement, pop on return. Stack lives in a
4535        // thread-local but is balanced by the RAII guard, so a
4536        // pool-shared connection cannot leak the override across
4537        // requests and an early `?` return still pops cleanly.
4538        match crate::runtime::within_clause::try_strip_within_prefix(query) {
4539            Ok(Some((scope, inner))) => {
4540                let _scope_guard = ScopeOverrideGuard::install(scope);
4541                // Re-enter the inner path, NOT `execute_query`, so the
4542                // slow-query lifecycle hook records exactly one row per
4543                // top-level statement (the WITHIN-stripped form would
4544                // double-record).
4545                return self.execute_query_inner(inner);
4546            }
4547            Ok(None) => {}
4548            Err(msg) => return Err(RedDBError::Query(msg)),
4549        }
4550
4551        // `EXPLAIN <stmt>` — introspection. Runs the planner on the
4552        // inner statement (WITHOUT executing it) and returns the
4553        // CanonicalLogicalNode tree as rows so the caller can see the
4554        // operator shape and estimated cost. `EXPLAIN ALTER FOR ...`
4555        // is a distinct schema-diff command and continues down the
4556        // regular SQL path.
4557        if let Some(inner) = strip_explain_prefix(query) {
4558            return self.explain_as_rows(query, inner);
4559        }
4560
4561        // `SET LOCAL TENANT '<id>'` — write the per-transaction tenant
4562        // override and return. Outside a transaction the statement is
4563        // an error (matches PG semantics: SET LOCAL only takes effect
4564        // within an active transaction).
4565        if let Some(value) = parse_set_local_tenant(query)? {
4566            let conn_id = current_connection_id();
4567            if !self.inner.tx_contexts.read().contains_key(&conn_id) {
4568                return Err(RedDBError::Query(
4569                    "SET LOCAL TENANT requires an active transaction".to_string(),
4570                ));
4571            }
4572            self.inner
4573                .tx_local_tenants
4574                .write()
4575                .insert(conn_id, value.clone());
4576            return Ok(RuntimeQueryResult::ok_message(
4577                query.to_string(),
4578                &match &value {
4579                    Some(id) => format!("local tenant set: {id}"),
4580                    None => "local tenant cleared".to_string(),
4581                },
4582                "set_local_tenant",
4583            ));
4584        }
4585
4586        if super::red_schema::is_system_schema_write(query) {
4587            return Err(RedDBError::Query(
4588                super::red_schema::READ_ONLY_ERROR.to_string(),
4589            ));
4590        }
4591
4592        if let Some(create_source) = super::analytics_source_catalog::parse_create_statement(query)?
4593        {
4594            return self.execute_create_analytics_source(query, create_source);
4595        }
4596
4597        // Issue #790 — `READ METRIC <path>` is intentionally rejected at
4598        // v0. The descriptor itself is readable through
4599        // `red.analytics.metrics`; the *output* read returns a
4600        // structured error so callers can tell "execution engine not yet
4601        // built" apart from "metric does not exist".
4602        if let Some(path) = super::metric_descriptor_catalog::parse_read_metric_statement(query) {
4603            return Err(super::metric_descriptor_catalog::read_output_unsupported(
4604                &path,
4605            ));
4606        }
4607
4608        // Issue #918 / ADR 0035 — leaderboard rank capability catalog
4609        // declarations are still recognised before the general parser.
4610        // Rank reads themselves are parser AST nodes, including Redis-flavor
4611        // Z* sugar that desugars to the same canonical rank shapes.
4612        if let Some(parsed) = super::ranking_descriptor_catalog::parse_create_ranking(query) {
4613            return self.execute_create_ranking(query, parsed?);
4614        }
4615        if super::ranking_descriptor_catalog::parse_show_rankings(query) {
4616            return self.execute_show_rankings(query);
4617        }
4618
4619        let rewritten_query = super::red_schema::rewrite_virtual_names(query);
4620        let execution_query = rewritten_query.as_deref().unwrap_or(query);
4621
4622        let frame = super::statement_frame::StatementExecutionFrame::build(self, execution_query)?;
4623        let _frame_guards = frame.install(self);
4624
4625        // Phase 6 logging: enter a span stamped with conn_id / tenant
4626        // / query_len. Every downstream tracing::info!/warn!/error!
4627        // inherits these fields — no need to thread them manually
4628        // through storage/scan layers. Entered AFTER the WITHIN /
4629        // SET LOCAL TENANT resolution above so the span reflects the
4630        // effective scope for this statement.
4631        let _log_span = crate::telemetry::span::query_span(query).entered();
4632
4633        // ── CTE prelude (#41) — `WITH x AS (...) SELECT ... FROM x` ──
4634        if let Some(rewritten) = frame.prepare_cte(execution_query)? {
4635            return self.execute_query_expr(rewritten);
4636        }
4637
4638        // ── TURBO: bypass SQL parse for SELECT * FROM x WHERE _entity_id = N ──
4639        if !self.inner.query_audit.has_rules() {
4640            if let Some(result) = self.try_fast_entity_lookup(execution_query) {
4641                return result;
4642            }
4643        }
4644
4645        // ── Result cache: return cached result if still fresh (30s TTL) ──
4646        if !self.inner.query_audit.has_rules() {
4647            if let Some(result) = frame.read_result_cache(self) {
4648                return Ok(result);
4649            }
4650        }
4651
4652        let prepared = frame.prepare_statement(self, execution_query)?;
4653        let mode = prepared.mode;
4654        let expr = prepared.expr;
4655
4656        let statement = query_expr_name(&expr);
4657        let result_cache_scopes = query_expr_result_cache_scopes(&expr);
4658        let control_event_specs = query_control_event_specs(&expr);
4659        let query_audit_plan = query_audit_plan(&expr);
4660
4661        let _lock_guard = match frame.prepare_dispatch(self, &expr) {
4662            Ok(guard) => guard,
4663            Err(err) => {
4664                let outcome = control_event_outcome_for_error(&err);
4665                for spec in &control_event_specs {
4666                    self.emit_control_event(
4667                        spec.kind,
4668                        outcome,
4669                        spec.action,
4670                        spec.resource.clone(),
4671                        Some(err.to_string()),
4672                        spec.fields.clone(),
4673                    )?;
4674                }
4675                return Err(err);
4676            }
4677        };
4678        let frame_iface: &dyn super::statement_frame::ReadFrame = &frame;
4679        let query_audit_started = std::time::Instant::now();
4680
4681        let query_result = match expr {
4682            QueryExpr::Graph(_) | QueryExpr::Path(_) => {
4683                // Apply MVCC visibility + RLS gate while materialising the
4684                // graph: every node entity is screened against the source
4685                // collection's policy chain (basic and `Nodes`-targeted)
4686                // and dropped when the caller's tenant / role doesn't
4687                // admit it. Edges are pruned automatically because the
4688                // graph builder skips edges whose endpoints aren't in
4689                // `allowed_nodes`.
4690                let (graph, node_properties, edge_properties) =
4691                    self.materialize_graph_with_rls()?;
4692                let result =
4693                    crate::storage::query::unified::UnifiedExecutor::execute_on_with_graph_properties(
4694                        &graph,
4695                        &expr,
4696                        node_properties,
4697                        edge_properties,
4698                    )
4699                        .map_err(|err| RedDBError::Query(err.to_string()))?;
4700
4701                Ok(RuntimeQueryResult {
4702                    query: query.to_string(),
4703                    mode,
4704                    statement,
4705                    engine: "materialized-graph",
4706                    result,
4707                    affected_rows: 0,
4708                    statement_type: "select",
4709                    bookmark: None,
4710                })
4711            }
4712            QueryExpr::Table(table) => {
4713                let table = self.resolve_table_expr_subqueries(
4714                    table,
4715                    &frame as &dyn super::statement_frame::ReadFrame,
4716                )?;
4717                // Table-valued functions (e.g. components(g)) dispatch to a
4718                // read-only executor before any catalog/virtual-table routing
4719                // (issue #795).
4720                if let Some(TableSource::Function {
4721                    name,
4722                    args,
4723                    named_args,
4724                }) = table.source.clone()
4725                {
4726                    // The graph-collection form is cacheable (issue #802): the
4727                    // result-cache read at the top of this function keys on the
4728                    // query string, and `result_cache_scopes` carries the graph
4729                    // collection (see `collect_table_source_scopes`) so a write
4730                    // to it invalidates the entry. Deterministic algorithm
4731                    // output is worth caching at any row count, so the write
4732                    // bypasses the generic ≤5-row payload heuristic.
4733                    let tvf_result = RuntimeQueryResult {
4734                        query: query.to_string(),
4735                        mode,
4736                        statement,
4737                        engine: "runtime-graph-tvf",
4738                        result: self.execute_table_function(&name, &args, &named_args)?,
4739                        affected_rows: 0,
4740                        statement_type: "select",
4741                        bookmark: None,
4742                    };
4743                    frame.write_result_cache(self, &tvf_result, result_cache_scopes.clone());
4744                    return Ok(tvf_result);
4745                }
4746                // Inline-graph TVF (issue #799): the graph is supplied by two
4747                // subqueries instead of a collection reference. Unlike the
4748                // graph-collection form, the result IS cacheable — its cache
4749                // key is the query string (the result-cache read at the top of
4750                // `execute_query_inner` keys on it) and `result_cache_scopes`
4751                // already carries the `nodes`/`edges` source collections, so a
4752                // write to any of them invalidates the entry.
4753                if let Some(TableSource::InlineGraphFunction {
4754                    name,
4755                    nodes,
4756                    edges,
4757                    named_args,
4758                }) = table.source.clone()
4759                {
4760                    let inline_result = RuntimeQueryResult {
4761                        query: query.to_string(),
4762                        mode,
4763                        statement,
4764                        engine: "runtime-graph-tvf-inline",
4765                        result: self.execute_inline_graph_function(
4766                            &name,
4767                            &nodes,
4768                            &edges,
4769                            &named_args,
4770                        )?,
4771                        affected_rows: 0,
4772                        statement_type: "select",
4773                        bookmark: None,
4774                    };
4775                    frame.write_result_cache(self, &inline_result, result_cache_scopes);
4776                    return Ok(inline_result);
4777                }
4778                if super::red_schema::is_virtual_table(&table.table) {
4779                    return Ok(RuntimeQueryResult {
4780                        query: query.to_string(),
4781                        mode,
4782                        statement,
4783                        engine: "runtime-red-schema",
4784                        result: super::red_schema::red_query(
4785                            self,
4786                            &table.table,
4787                            &table,
4788                            &frame as &dyn super::statement_frame::ReadFrame,
4789                        )?,
4790                        affected_rows: 0,
4791                        statement_type: "select",
4792                        bookmark: None,
4793                    });
4794                }
4795
4796                // `<graph>.<output>` analytics virtual view (issue #800).
4797                // Recomputed on demand — intentionally not result-cached, so it
4798                // always reflects the current graph data.
4799                if let Some(view_result) = self.try_resolve_analytics_view(
4800                    &table,
4801                    &frame as &dyn super::statement_frame::ReadFrame,
4802                )? {
4803                    return Ok(RuntimeQueryResult {
4804                        query: query.to_string(),
4805                        mode,
4806                        statement,
4807                        engine: "runtime-graph-analytics-view",
4808                        result: view_result,
4809                        affected_rows: 0,
4810                        statement_type: "select",
4811                        bookmark: None,
4812                    });
4813                }
4814
4815                if let Some(result) = self.execute_probabilistic_select(&table)? {
4816                    return Ok(RuntimeQueryResult {
4817                        query: query.to_string(),
4818                        mode,
4819                        statement,
4820                        engine: "runtime-probabilistic",
4821                        result,
4822                        affected_rows: 0,
4823                        statement_type: "select",
4824                        bookmark: None,
4825                    });
4826                }
4827
4828                // Foreign-table intercept (Phase 3.2.2 PG parity).
4829                //
4830                // When the referenced table matches a `CREATE FOREIGN TABLE`
4831                // registration, short-circuit into the FDW scan. Phase 3.2
4832                // wrappers don't yet support pushdown, so filters/projections
4833                // apply post-scan via `apply_foreign_table_filters` — good
4834                // enough for correctness; perf work lands in 3.2.3.
4835                if self.inner.foreign_tables.is_foreign_table(&table.table) {
4836                    let records = self
4837                        .inner
4838                        .foreign_tables
4839                        .scan(&table.table)
4840                        .map_err(|e| RedDBError::Internal(e.to_string()))?;
4841                    let result = apply_foreign_table_filters(records, &table);
4842                    return Ok(RuntimeQueryResult {
4843                        query: query.to_string(),
4844                        mode,
4845                        statement,
4846                        engine: "runtime-fdw",
4847                        result,
4848                        affected_rows: 0,
4849                        statement_type: "select",
4850                        bookmark: None,
4851                    });
4852                }
4853
4854                // Row-Level Security enforcement (Phase 2.5.2 PG parity).
4855                //
4856                // When RLS is enabled on this table, fetch every policy
4857                // that applies to the current (role, SELECT) pair and
4858                // fold them into the query's WHERE clause: policies
4859                // OR-combine (any of them admitting the row is enough),
4860                // then AND into the caller's existing filter.
4861                //
4862                // Anonymous callers (no thread-local identity) pass
4863                // `role = None`; policies with a specific `TO role`
4864                // clause skip, but `TO PUBLIC` policies still apply.
4865                //
4866                // When `inject_rls_filters` returns `None` the table has
4867                // RLS enabled but no policy admits the caller's role —
4868                // short-circuit with an empty result set instead of
4869                // synthesising a contradiction filter.
4870                let Some(table_with_rls) = self.authorize_relational_table_select(
4871                    table,
4872                    &frame as &dyn super::statement_frame::ReadFrame,
4873                )?
4874                else {
4875                    let empty = crate::storage::query::unified::UnifiedResult::empty();
4876                    return Ok(RuntimeQueryResult {
4877                        query: query.to_string(),
4878                        mode,
4879                        statement,
4880                        engine: "runtime-table-rls",
4881                        result: empty,
4882                        affected_rows: 0,
4883                        statement_type: "select",
4884                        bookmark: None,
4885                    });
4886                };
4887                Ok(RuntimeQueryResult {
4888                    query: query.to_string(),
4889                    mode,
4890                    statement,
4891                    engine: "runtime-table",
4892                    // #885: lend the frame-owned row-buffer arena to the
4893                    // streaming path so chunk buffers are reused across
4894                    // this statement's chunk-fetches instead of allocated
4895                    // fresh per chunk. This is the table-query dispatch
4896                    // that runs under a `StatementExecutionFrame`; the
4897                    // frameless prepared/subquery paths keep `None`.
4898                    result: execute_runtime_table_query_in(
4899                        &self.inner.db,
4900                        &table_with_rls,
4901                        Some(&self.inner.index_store),
4902                        Some(frame.row_arena()),
4903                    )?,
4904                    affected_rows: 0,
4905                    statement_type: "select",
4906                    bookmark: None,
4907                })
4908            }
4909            QueryExpr::Join(join) => {
4910                // Fold per-table RLS filters into each `QueryExpr::Table`
4911                // leaf of the join tree before executing. Without this
4912                // the join executor scans both tables raw and ignores
4913                // policies — a `WITHIN TENANT 'x'` against a join of
4914                // two tenant-scoped tables would leak cross-tenant rows.
4915                // When any leaf has RLS enabled and zero matching policy,
4916                // short-circuit to an empty join result instead of
4917                // emitting a contradiction filter.
4918                let join_with_rls = match self.authorize_relational_join_select(
4919                    join,
4920                    &frame as &dyn super::statement_frame::ReadFrame,
4921                )? {
4922                    Some(j) => j,
4923                    None => {
4924                        return Ok(RuntimeQueryResult {
4925                            query: query.to_string(),
4926                            mode,
4927                            statement,
4928                            engine: "runtime-join-rls",
4929                            result: crate::storage::query::unified::UnifiedResult::empty(),
4930                            affected_rows: 0,
4931                            statement_type: "select",
4932                            bookmark: None,
4933                        });
4934                    }
4935                };
4936                Ok(RuntimeQueryResult {
4937                    query: query.to_string(),
4938                    mode,
4939                    statement,
4940                    engine: "runtime-join",
4941                    result: execute_runtime_join_query(&self.inner.db, &join_with_rls)?,
4942                    affected_rows: 0,
4943                    statement_type: "select",
4944                    bookmark: None,
4945                })
4946            }
4947            QueryExpr::Vector(vector) => Ok(RuntimeQueryResult {
4948                query: query.to_string(),
4949                mode,
4950                statement,
4951                engine: "runtime-vector",
4952                result: execute_runtime_vector_query(&self.inner.db, &vector)?,
4953                affected_rows: 0,
4954                statement_type: "select",
4955                bookmark: None,
4956            }),
4957            QueryExpr::Hybrid(hybrid) => Ok(RuntimeQueryResult {
4958                query: query.to_string(),
4959                mode,
4960                statement,
4961                engine: "runtime-hybrid",
4962                result: execute_runtime_hybrid_query(&self.inner.db, &hybrid)?,
4963                affected_rows: 0,
4964                statement_type: "select",
4965                bookmark: None,
4966            }),
4967            QueryExpr::RankOf(ref rank) => self.execute_rank_of(query, rank),
4968            QueryExpr::ApproxRankOf(ref rank) => self.execute_approx_rank_of(query, rank),
4969            QueryExpr::RankRange(ref range) => self.execute_rank_range(query, range),
4970            // DML execution
4971            QueryExpr::Insert(ref insert) if super::red_schema::is_virtual_table(&insert.table) => {
4972                Err(RedDBError::Query(
4973                    super::red_schema::READ_ONLY_ERROR.to_string(),
4974                ))
4975            }
4976            QueryExpr::Update(ref update) if super::red_schema::is_virtual_table(&update.table) => {
4977                Err(RedDBError::Query(
4978                    super::red_schema::READ_ONLY_ERROR.to_string(),
4979                ))
4980            }
4981            QueryExpr::Delete(ref delete) if super::red_schema::is_virtual_table(&delete.table) => {
4982                Err(RedDBError::Query(
4983                    super::red_schema::READ_ONLY_ERROR.to_string(),
4984                ))
4985            }
4986            QueryExpr::Insert(ref insert) => self
4987                .with_deferred_store_wal_for_dml(self.insert_may_emit_events(insert), || {
4988                    self.execute_insert(query, insert)
4989                }),
4990            QueryExpr::Update(ref update) => self
4991                .with_deferred_store_wal_for_dml(self.update_may_emit_events(update), || {
4992                    self.execute_update(query, update)
4993                }),
4994            QueryExpr::Delete(ref delete) => self
4995                .with_deferred_store_wal_for_dml(self.delete_may_emit_events(delete), || {
4996                    self.execute_delete(query, delete)
4997                }),
4998            // DDL execution
4999            QueryExpr::CreateTable(ref create) => self.execute_create_table(query, create),
5000            QueryExpr::CreateCollection(ref create) => {
5001                self.execute_create_collection(query, create)
5002            }
5003            QueryExpr::CreateVector(ref create) => self.execute_create_vector(query, create),
5004            QueryExpr::DropTable(ref drop_tbl) => self.execute_drop_table(query, drop_tbl),
5005            QueryExpr::DropGraph(ref drop_graph) => self.execute_drop_graph(query, drop_graph),
5006            QueryExpr::DropVector(ref drop_vector) => self.execute_drop_vector(query, drop_vector),
5007            QueryExpr::DropDocument(ref drop_document) => {
5008                self.execute_drop_document(query, drop_document)
5009            }
5010            QueryExpr::DropKv(ref drop_kv) => self.execute_drop_kv(query, drop_kv),
5011            QueryExpr::DropCollection(ref drop_collection) => {
5012                self.execute_drop_collection(query, drop_collection)
5013            }
5014            QueryExpr::Truncate(ref truncate) => self.execute_truncate(query, truncate),
5015            QueryExpr::AlterTable(ref alter) => self.execute_alter_table(query, alter),
5016            QueryExpr::ExplainAlter(ref explain) => self.execute_explain_alter(query, explain),
5017            // Graph analytics commands
5018            QueryExpr::GraphCommand(ref cmd) => self.execute_graph_command(query, cmd),
5019            // Search commands
5020            QueryExpr::SearchCommand(ref cmd) => self.execute_search_command(query, cmd),
5021            // ASK: RAG query with LLM synthesis
5022            QueryExpr::Ask(ref ask) => self.execute_ask(query, ask),
5023            QueryExpr::CreateIndex(ref create_idx) => self.execute_create_index(query, create_idx),
5024            QueryExpr::DropIndex(ref drop_idx) => self.execute_drop_index(query, drop_idx),
5025            QueryExpr::ProbabilisticCommand(ref cmd) => {
5026                self.execute_probabilistic_command(query, cmd)
5027            }
5028            // Time-series DDL
5029            QueryExpr::CreateTimeSeries(ref ts) => self.execute_create_timeseries(query, ts),
5030            QueryExpr::CreateMetric(ref metric) => self.execute_create_metric(query, metric),
5031            QueryExpr::AlterMetric(ref alter) => self.execute_alter_metric(query, alter),
5032            QueryExpr::CreateSlo(ref slo) => self.execute_create_slo(query, slo),
5033            QueryExpr::DropTimeSeries(ref ts) => self.execute_drop_timeseries(query, ts),
5034            // Queue DDL and commands
5035            QueryExpr::CreateQueue(ref q) => self.execute_create_queue(query, q),
5036            QueryExpr::AlterQueue(ref q) => self.execute_alter_queue(query, q),
5037            QueryExpr::DropQueue(ref q) => self.execute_drop_queue(query, q),
5038            QueryExpr::QueueSelect(ref q) => self.execute_queue_select(query, q),
5039            QueryExpr::QueueCommand(ref cmd) => self.execute_queue_command(query, cmd),
5040            QueryExpr::EventsBackfill(ref backfill) => {
5041                self.execute_events_backfill(query, backfill)
5042            }
5043            QueryExpr::EventsBackfillStatus { ref collection } => Err(RedDBError::Query(format!(
5044                "EVENTS BACKFILL STATUS for '{collection}' is not implemented in this slice"
5045            ))),
5046            QueryExpr::KvCommand(ref cmd) => self.execute_kv_command(query, cmd),
5047            QueryExpr::ConfigCommand(ref cmd) => self.execute_config_command(query, cmd),
5048            QueryExpr::CreateTree(ref tree) => self.execute_create_tree(query, tree),
5049            QueryExpr::DropTree(ref tree) => self.execute_drop_tree(query, tree),
5050            QueryExpr::TreeCommand(ref cmd) => self.execute_tree_command(query, cmd),
5051            // SET CONFIG key = value
5052            QueryExpr::SetConfig { ref key, ref value } => {
5053                if key.starts_with("red.secret.") {
5054                    return Err(RedDBError::Query(
5055                        "red.secret.* is reserved for vault secrets; use SET SECRET".to_string(),
5056                    ));
5057                }
5058                if key.starts_with("red.secrets.") {
5059                    return Err(RedDBError::Query(
5060                        "red.secrets.* is reserved for vault secrets; use SET SECRET".to_string(),
5061                    ));
5062                }
5063                match self.check_managed_config_write_for_set_config(key) {
5064                    Err(err) => Err(err),
5065                    Ok(()) => {
5066                        let store = self.inner.db.store();
5067                        let json_val = match value {
5068                            Value::Text(s) => crate::serde_json::Value::String(s.to_string()),
5069                            Value::Integer(n) => crate::serde_json::Value::Number(*n as f64),
5070                            Value::Float(n) => crate::serde_json::Value::Number(*n),
5071                            Value::Boolean(b) => crate::serde_json::Value::Bool(*b),
5072                            _ => crate::serde_json::Value::String(value.to_string()),
5073                        };
5074                        store.set_config_tree(key, &json_val);
5075                        update_current_config_value(key, value.clone());
5076                        // Config changes can flip runtime behavior mid-session
5077                        // (auto_decrypt, auto_encrypt, etc.) — invalidate the
5078                        // result cache so subsequent reads re-execute against
5079                        // the new config.
5080                        self.invalidate_result_cache();
5081                        Ok(RuntimeQueryResult::ok_message(
5082                            query.to_string(),
5083                            &format!("config set: {key}"),
5084                            "set",
5085                        ))
5086                    }
5087                }
5088            }
5089            // SET SECRET key = value
5090            QueryExpr::SetSecret { ref key, ref value } => {
5091                if key.starts_with("red.config.") {
5092                    return Err(RedDBError::Query(
5093                        "red.config.* is reserved for config; use SET CONFIG".to_string(),
5094                    ));
5095                }
5096                let auth_store = self.inner.auth_store.read().clone().ok_or_else(|| {
5097                    RedDBError::Query("SET SECRET requires an enabled, unsealed vault".to_string())
5098                })?;
5099                if matches!(value, Value::Null) {
5100                    auth_store
5101                        .vault_kv_try_delete(key)
5102                        .map_err(|err| RedDBError::Query(err.to_string()))?;
5103                    update_current_secret_value(key, None);
5104                    self.invalidate_result_cache();
5105                    return Ok(RuntimeQueryResult::ok_message(
5106                        query.to_string(),
5107                        &format!("secret deleted: {key}"),
5108                        "delete_secret",
5109                    ));
5110                }
5111                let value = secret_sql_value_to_string(value)?;
5112                auth_store
5113                    .vault_kv_try_set(key.clone(), value.clone())
5114                    .map_err(|err| RedDBError::Query(err.to_string()))?;
5115                update_current_secret_value(key, Some(value));
5116                self.invalidate_result_cache();
5117                Ok(RuntimeQueryResult::ok_message(
5118                    query.to_string(),
5119                    &format!("secret set: {key}"),
5120                    "set_secret",
5121                ))
5122            }
5123            // DELETE SECRET key
5124            QueryExpr::DeleteSecret { ref key } => {
5125                let auth_store = self.inner.auth_store.read().clone().ok_or_else(|| {
5126                    RedDBError::Query(
5127                        "DELETE SECRET requires an enabled, unsealed vault".to_string(),
5128                    )
5129                })?;
5130                let deleted = auth_store
5131                    .vault_kv_try_delete(key)
5132                    .map_err(|err| RedDBError::Query(err.to_string()))?;
5133                if deleted {
5134                    update_current_secret_value(key, None);
5135                }
5136                self.invalidate_result_cache();
5137                Ok(RuntimeQueryResult::ok_message(
5138                    query.to_string(),
5139                    &format!("secret deleted: {key}"),
5140                    if deleted {
5141                        "delete_secret"
5142                    } else {
5143                        "delete_secret_not_found"
5144                    },
5145                ))
5146            }
5147            // SHOW SECRET[S] [prefix]
5148            QueryExpr::ShowSecrets { ref prefix } => {
5149                let auth_store = self.inner.auth_store.read().clone().ok_or_else(|| {
5150                    RedDBError::Query("SHOW SECRET requires an enabled, unsealed vault".to_string())
5151                })?;
5152                if !auth_store.is_vault_backed() {
5153                    return Err(RedDBError::Query(
5154                        "SHOW SECRET requires an enabled, unsealed vault".to_string(),
5155                    ));
5156                }
5157                let mut keys = auth_store.vault_kv_keys();
5158                keys.sort();
5159                let mut result = UnifiedResult::with_columns(vec![
5160                    "key".into(),
5161                    "value".into(),
5162                    "status".into(),
5163                ]);
5164                for key in keys {
5165                    if let Some(ref pfx) = prefix {
5166                        if !key.starts_with(pfx) {
5167                            continue;
5168                        }
5169                    }
5170                    let mut record = UnifiedRecord::new();
5171                    record.set("key", Value::text(key));
5172                    record.set("value", Value::text("***"));
5173                    record.set("status", Value::text("active"));
5174                    result.push(record);
5175                }
5176                Ok(RuntimeQueryResult {
5177                    query: query.to_string(),
5178                    mode,
5179                    statement: "show_secrets",
5180                    engine: "runtime-secret",
5181                    result,
5182                    affected_rows: 0,
5183                    statement_type: "select",
5184                    bookmark: None,
5185                })
5186            }
5187            // SHOW CONFIG [prefix] [AS JSON|FORMAT JSON]
5188            QueryExpr::ShowConfig {
5189                ref prefix,
5190                as_json,
5191            } => {
5192                let store = self.inner.db.store();
5193                let all_collections = store.list_collections();
5194                if !all_collections.contains(&"red_config".to_string()) {
5195                    if as_json {
5196                        return Ok(show_config_json_result(
5197                            query,
5198                            mode,
5199                            prefix,
5200                            crate::serde_json::Value::Object(crate::serde_json::Map::new()),
5201                        ));
5202                    }
5203                    let result = UnifiedResult::with_columns(vec!["key".into(), "value".into()]);
5204                    return Ok(RuntimeQueryResult {
5205                        query: query.to_string(),
5206                        mode,
5207                        statement: "show_config",
5208                        engine: "runtime-config",
5209                        result,
5210                        affected_rows: 0,
5211                        statement_type: "select",
5212                        bookmark: None,
5213                    });
5214                }
5215                let manager = store
5216                    .get_collection("red_config")
5217                    .ok_or_else(|| RedDBError::NotFound("red_config".to_string()))?;
5218                let entities = manager.query_all(|_| true);
5219                let mut latest = std::collections::BTreeMap::<String, (u64, Value, Value)>::new();
5220                for entity in entities {
5221                    if let EntityData::Row(ref row) = entity.data {
5222                        if let Some(ref named) = row.named {
5223                            let key_val = named.get("key").cloned().unwrap_or(Value::Null);
5224                            let val = named.get("value").cloned().unwrap_or(Value::Null);
5225                            let key_str = match &key_val {
5226                                Value::Text(s) => s.as_ref(),
5227                                _ => continue,
5228                            };
5229                            if let Some(ref pfx) = prefix {
5230                                if !key_str.starts_with(pfx.as_str()) {
5231                                    continue;
5232                                }
5233                            }
5234                            let entity_id = entity.id.raw();
5235                            match latest.get(key_str) {
5236                                Some((prev_id, _, _)) if *prev_id > entity_id => {}
5237                                _ => {
5238                                    latest.insert(key_str.to_string(), (entity_id, key_val, val));
5239                                }
5240                            }
5241                        }
5242                    }
5243                }
5244                if as_json {
5245                    let mut tree = crate::serde_json::Value::Object(crate::serde_json::Map::new());
5246                    for (key, (_, _, val)) in latest {
5247                        let relative = match prefix {
5248                            Some(pfx) if key == *pfx => "",
5249                            Some(pfx) => key
5250                                .strip_prefix(pfx.as_str())
5251                                .and_then(|tail| tail.strip_prefix('.'))
5252                                .unwrap_or(key.as_str()),
5253                            None => key.as_str(),
5254                        };
5255                        insert_config_json_path(
5256                            &mut tree,
5257                            relative,
5258                            crate::presentation::entity_json::storage_value_to_json(&val),
5259                        );
5260                    }
5261                    return Ok(show_config_json_result(query, mode, prefix, tree));
5262                }
5263                let mut result = UnifiedResult::with_columns(vec!["key".into(), "value".into()]);
5264                for (_, key_val, val) in latest.into_values() {
5265                    let mut record = UnifiedRecord::new();
5266                    record.set("key", key_val);
5267                    record.set("value", val);
5268                    result.push(record);
5269                }
5270                Ok(RuntimeQueryResult {
5271                    query: query.to_string(),
5272                    mode,
5273                    statement: "show_config",
5274                    engine: "runtime-config",
5275                    result,
5276                    affected_rows: 0,
5277                    statement_type: "select",
5278                    bookmark: None,
5279                })
5280            }
5281            // Session-local multi-tenancy handle (Phase 2.5.3).
5282            //
5283            // SET TENANT 'id' / SET TENANT NULL / RESET TENANT — writes
5284            // the thread-local; SHOW TENANT returns it. Paired with the
5285            // CURRENT_TENANT() scalar for use in RLS policies.
5286            QueryExpr::SetTenant(ref value) => {
5287                match value {
5288                    Some(id) => set_current_tenant(id.clone()),
5289                    None => clear_current_tenant(),
5290                }
5291                Ok(RuntimeQueryResult::ok_message(
5292                    query.to_string(),
5293                    &match value {
5294                        Some(id) => format!("tenant set: {id}"),
5295                        None => "tenant cleared".to_string(),
5296                    },
5297                    "set_tenant",
5298                ))
5299            }
5300            QueryExpr::ShowTenant => {
5301                let mut result = UnifiedResult::with_columns(vec!["tenant".into()]);
5302                let mut record = UnifiedRecord::new();
5303                record.set(
5304                    "tenant",
5305                    current_tenant().map(Value::text).unwrap_or(Value::Null),
5306                );
5307                result.push(record);
5308                Ok(RuntimeQueryResult {
5309                    query: query.to_string(),
5310                    mode,
5311                    statement: "show_tenant",
5312                    engine: "runtime-tenant",
5313                    result,
5314                    affected_rows: 0,
5315                    statement_type: "select",
5316                    bookmark: None,
5317                })
5318            }
5319            // Transaction control (Phase 2.3 PG parity).
5320            //
5321            // BEGIN allocates a real `Xid` and stores a `TxnContext` keyed by
5322            // the current connection's id. COMMIT/ROLLBACK release it through
5323            // the `SnapshotManager` so future snapshots see the correct set of
5324            // active/aborted transactions.
5325            //
5326            // Tuple stamping (xmin/xmax) and read-path visibility filtering
5327            // land in Phase 2.3.2 — this dispatch only manages the snapshot
5328            // registry. Statements running outside a TxnContext still behave
5329            // as autocommit (xid=0 → visible to every snapshot).
5330            QueryExpr::TransactionControl(ref ctl) => {
5331                use crate::storage::query::ast::TxnControl;
5332                use crate::storage::transaction::snapshot::{TxnContext, Xid};
5333                use crate::storage::transaction::IsolationLevel;
5334
5335                // Phase 2.3 keys transactions by a thread-local connection id.
5336                // The stdio/gRPC paths wire a real per-connection id later;
5337                // for embedded use (one RedDBRuntime per process-ish caller)
5338                // we fall back to a deterministic placeholder.
5339                let conn_id = current_connection_id();
5340
5341                let (kind, msg) = match ctl {
5342                    TxnControl::Begin => {
5343                        let mgr = Arc::clone(&self.inner.snapshot_manager);
5344                        let xid = mgr.begin();
5345                        let snapshot = mgr.snapshot(xid);
5346                        let ctx = TxnContext {
5347                            xid,
5348                            isolation: IsolationLevel::SnapshotIsolation,
5349                            snapshot,
5350                            savepoints: Vec::new(),
5351                            released_sub_xids: Vec::new(),
5352                        };
5353                        self.inner.tx_contexts.write().insert(conn_id, ctx);
5354                        ("begin", format!("BEGIN — xid={xid} (snapshot isolation)"))
5355                    }
5356                    TxnControl::Commit => {
5357                        // SET LOCAL TENANT ends with the transaction.
5358                        self.inner.tx_local_tenants.write().remove(&conn_id);
5359                        let ctx = self.inner.tx_contexts.write().remove(&conn_id);
5360                        match ctx {
5361                            Some(ctx) => {
5362                                let mut own_xids = std::collections::HashSet::new();
5363                                own_xids.insert(ctx.xid);
5364                                for (_, sub) in &ctx.savepoints {
5365                                    own_xids.insert(*sub);
5366                                }
5367                                for sub in &ctx.released_sub_xids {
5368                                    own_xids.insert(*sub);
5369                                }
5370                                if let Err(err) = self.check_table_row_write_conflicts(
5371                                    conn_id,
5372                                    &ctx.snapshot,
5373                                    &own_xids,
5374                                ) {
5375                                    for (_, sub) in &ctx.savepoints {
5376                                        self.inner.snapshot_manager.rollback(*sub);
5377                                    }
5378                                    for sub in &ctx.released_sub_xids {
5379                                        self.inner.snapshot_manager.rollback(*sub);
5380                                    }
5381                                    self.inner.snapshot_manager.rollback(ctx.xid);
5382                                    self.revive_pending_versioned_updates(conn_id);
5383                                    self.revive_pending_tombstones(conn_id);
5384                                    self.discard_pending_kv_watch_events(conn_id);
5385                                    self.discard_pending_queue_wakes(conn_id);
5386                                    self.discard_pending_store_wal_actions(conn_id);
5387                                    self.release_pending_claim_locks(conn_id);
5388                                    return Err(err);
5389                                }
5390                                self.restore_pending_write_stamps(conn_id);
5391                                if let Err(err) = self.flush_pending_store_wal_actions(conn_id) {
5392                                    for (_, sub) in &ctx.savepoints {
5393                                        self.inner.snapshot_manager.rollback(*sub);
5394                                    }
5395                                    for sub in &ctx.released_sub_xids {
5396                                        self.inner.snapshot_manager.rollback(*sub);
5397                                    }
5398                                    self.inner.snapshot_manager.rollback(ctx.xid);
5399                                    self.revive_pending_versioned_updates(conn_id);
5400                                    self.revive_pending_tombstones(conn_id);
5401                                    self.discard_pending_kv_watch_events(conn_id);
5402                                    self.discard_pending_queue_wakes(conn_id);
5403                                    self.release_pending_claim_locks(conn_id);
5404                                    return Err(err);
5405                                }
5406                                // Phase 2.3.2e: commit every open sub-xid
5407                                // so they also become visible. Their
5408                                // work is promoted to the parent txn's
5409                                // result exactly like a RELEASE would
5410                                // have done.
5411                                for (_, sub) in &ctx.savepoints {
5412                                    self.inner.snapshot_manager.commit(*sub);
5413                                }
5414                                for sub in &ctx.released_sub_xids {
5415                                    self.inner.snapshot_manager.commit(*sub);
5416                                }
5417                                self.inner.snapshot_manager.commit(ctx.xid);
5418                                self.finalize_pending_versioned_updates(conn_id);
5419                                self.finalize_pending_tombstones(conn_id);
5420                                self.finalize_pending_kv_watch_events(conn_id);
5421                                self.finalize_pending_queue_wakes(conn_id);
5422                                self.release_pending_claim_locks(conn_id);
5423                                ("commit", format!("COMMIT — xid={} committed", ctx.xid))
5424                            }
5425                            None => (
5426                                "commit",
5427                                "COMMIT outside transaction — no-op (autocommit)".to_string(),
5428                            ),
5429                        }
5430                    }
5431                    TxnControl::Rollback => {
5432                        self.inner.tx_local_tenants.write().remove(&conn_id);
5433                        let ctx = self.inner.tx_contexts.write().remove(&conn_id);
5434                        match ctx {
5435                            Some(ctx) => {
5436                                // Phase 2.3.2e: abort every open sub-xid
5437                                // too so their writes stay hidden.
5438                                for (_, sub) in &ctx.savepoints {
5439                                    self.inner.snapshot_manager.rollback(*sub);
5440                                }
5441                                for sub in &ctx.released_sub_xids {
5442                                    self.inner.snapshot_manager.rollback(*sub);
5443                                }
5444                                self.inner.snapshot_manager.rollback(ctx.xid);
5445                                // Phase 2.3.2b: tuples that the txn had
5446                                // xmax-stamped become live again — wipe xmax
5447                                // back to 0 so later snapshots see them.
5448                                self.revive_pending_versioned_updates(conn_id);
5449                                self.revive_pending_tombstones(conn_id);
5450                                self.discard_pending_kv_watch_events(conn_id);
5451                                self.discard_pending_queue_wakes(conn_id);
5452                                self.discard_pending_store_wal_actions(conn_id);
5453                                self.release_pending_claim_locks(conn_id);
5454                                ("rollback", format!("ROLLBACK — xid={} aborted", ctx.xid))
5455                            }
5456                            None => (
5457                                "rollback",
5458                                "ROLLBACK outside transaction — no-op (autocommit)".to_string(),
5459                            ),
5460                        }
5461                    }
5462                    // Phase 2.3.2e: savepoints map onto sub-xids. Each
5463                    // SAVEPOINT allocates a fresh xid and pushes it
5464                    // onto the per-txn stack so subsequent writes can
5465                    // be selectively rolled back. RELEASE pops without
5466                    // aborting; ROLLBACK TO aborts the sub-xid (and
5467                    // any nested ones) + revives their tombstones.
5468                    TxnControl::Savepoint(name) => {
5469                        let mgr = Arc::clone(&self.inner.snapshot_manager);
5470                        let mut guard = self.inner.tx_contexts.write();
5471                        match guard.get_mut(&conn_id) {
5472                            Some(ctx) => {
5473                                let sub = mgr.begin();
5474                                ctx.savepoints.push((name.clone(), sub));
5475                                ("savepoint", format!("SAVEPOINT {name} — sub_xid={sub}"))
5476                            }
5477                            None => (
5478                                "savepoint",
5479                                "SAVEPOINT outside transaction — no-op".to_string(),
5480                            ),
5481                        }
5482                    }
5483                    TxnControl::ReleaseSavepoint(name) => {
5484                        let mut guard = self.inner.tx_contexts.write();
5485                        match guard.get_mut(&conn_id) {
5486                            Some(ctx) => {
5487                                let pos = ctx
5488                                    .savepoints
5489                                    .iter()
5490                                    .position(|(n, _)| n == name)
5491                                    .ok_or_else(|| {
5492                                        RedDBError::Internal(format!(
5493                                            "savepoint {name} does not exist"
5494                                        ))
5495                                    })?;
5496                                // RELEASE pops the named savepoint and
5497                                // any nested ones. Their sub-xids move
5498                                // to `released_sub_xids` so they commit
5499                                // (or roll back) alongside the parent
5500                                // xid — PG semantics: released
5501                                // savepoints still contribute their
5502                                // work, but their names are gone.
5503                                let released = ctx.savepoints.len() - pos;
5504                                let popped: Vec<Xid> = ctx
5505                                    .savepoints
5506                                    .split_off(pos)
5507                                    .into_iter()
5508                                    .map(|(_, x)| x)
5509                                    .collect();
5510                                ctx.released_sub_xids.extend(popped);
5511                                (
5512                                    "release_savepoint",
5513                                    format!("RELEASE SAVEPOINT {name} — {released} level(s)"),
5514                                )
5515                            }
5516                            None => (
5517                                "release_savepoint",
5518                                "RELEASE outside transaction — no-op".to_string(),
5519                            ),
5520                        }
5521                    }
5522                    TxnControl::RollbackToSavepoint(name) => {
5523                        let mgr = Arc::clone(&self.inner.snapshot_manager);
5524                        // Splice out the savepoint + nested ones under
5525                        // a narrow lock, then run the snapshot-manager
5526                        // + tombstone side-effects without the tx map
5527                        // held so nothing re-enters.
5528                        let drop_result: Option<(Xid, Vec<Xid>)> = {
5529                            let mut guard = self.inner.tx_contexts.write();
5530                            if let Some(ctx) = guard.get_mut(&conn_id) {
5531                                let pos = ctx
5532                                    .savepoints
5533                                    .iter()
5534                                    .position(|(n, _)| n == name)
5535                                    .ok_or_else(|| {
5536                                        RedDBError::Internal(format!(
5537                                            "savepoint {name} does not exist"
5538                                        ))
5539                                    })?;
5540                                let savepoint_xid = ctx.savepoints[pos].1;
5541                                let aborted: Vec<Xid> = ctx
5542                                    .savepoints
5543                                    .split_off(pos)
5544                                    .into_iter()
5545                                    .map(|(_, x)| x)
5546                                    .collect();
5547                                Some((savepoint_xid, aborted))
5548                            } else {
5549                                None
5550                            }
5551                        };
5552
5553                        match drop_result {
5554                            Some((savepoint_xid, aborted)) => {
5555                                for x in &aborted {
5556                                    mgr.rollback(*x);
5557                                }
5558                                let reverted_updates =
5559                                    self.revive_versioned_updates_since(conn_id, savepoint_xid);
5560                                let revived = self.revive_tombstones_since(conn_id, savepoint_xid);
5561                                (
5562                                    "rollback_to_savepoint",
5563                                    format!(
5564                                        "ROLLBACK TO SAVEPOINT {name} — aborted {} sub_xid(s), reverted {reverted_updates} update(s), revived {revived} tombstone(s)",
5565                                        aborted.len(),
5566                                    ),
5567                                )
5568                            }
5569                            None => (
5570                                "rollback_to_savepoint",
5571                                "ROLLBACK TO outside transaction — no-op".to_string(),
5572                            ),
5573                        }
5574                    }
5575                };
5576                Ok(RuntimeQueryResult::ok_message(
5577                    query.to_string(),
5578                    &msg,
5579                    kind,
5580                ))
5581            }
5582            // Schema + Sequence DDL (Phase 1.3 PG parity).
5583            //
5584            // Schemas are lightweight logical namespaces: a CREATE SCHEMA call
5585            // just registers the name in `red_config` under `schema.{name}`.
5586            // Table lookups still happen by collection name; clients using
5587            // `schema.table` qualified names collapse to collection `schema.table`.
5588            //
5589            // Sequences persist a 64-bit counter + metadata (start, increment)
5590            // in `red_config` under `sequence.{name}.*`. Scalar callers
5591            // `nextval('name')` / `currval('name')` arrive with the MVCC phase
5592            // once we have a proper mutating-function dispatch path; for now the
5593            // DDL just establishes the catalog entry so clients don't error.
5594            QueryExpr::CreateSchema(ref q) => {
5595                let store = self.inner.db.store();
5596                let key = format!("schema.{}", q.name);
5597                if store.get_config(&key).is_some() {
5598                    if q.if_not_exists {
5599                        return Ok(RuntimeQueryResult::ok_message(
5600                            query.to_string(),
5601                            &format!("schema {} already exists — skipped", q.name),
5602                            "create_schema",
5603                        ));
5604                    }
5605                    return Err(RedDBError::Internal(format!(
5606                        "schema {} already exists",
5607                        q.name
5608                    )));
5609                }
5610                store.set_config_tree(&key, &crate::serde_json::Value::Bool(true));
5611                Ok(RuntimeQueryResult::ok_message(
5612                    query.to_string(),
5613                    &format!("schema {} created", q.name),
5614                    "create_schema",
5615                ))
5616            }
5617            QueryExpr::DropSchema(ref q) => {
5618                let store = self.inner.db.store();
5619                let key = format!("schema.{}", q.name);
5620                let existed = store.get_config(&key).is_some();
5621                if !existed && !q.if_exists {
5622                    return Err(RedDBError::Internal(format!(
5623                        "schema {} does not exist",
5624                        q.name
5625                    )));
5626                }
5627                // Remove marker from red_config via set to null.
5628                store.set_config_tree(&key, &crate::serde_json::Value::Null);
5629                let suffix = if q.cascade {
5630                    " (CASCADE accepted — tables untouched)"
5631                } else {
5632                    ""
5633                };
5634                Ok(RuntimeQueryResult::ok_message(
5635                    query.to_string(),
5636                    &format!("schema {} dropped{}", q.name, suffix),
5637                    "drop_schema",
5638                ))
5639            }
5640            QueryExpr::CreateSequence(ref q) => {
5641                let store = self.inner.db.store();
5642                let base = format!("sequence.{}", q.name);
5643                let start_key = format!("{base}.start");
5644                let incr_key = format!("{base}.increment");
5645                let curr_key = format!("{base}.current");
5646                if store.get_config(&start_key).is_some() {
5647                    if q.if_not_exists {
5648                        return Ok(RuntimeQueryResult::ok_message(
5649                            query.to_string(),
5650                            &format!("sequence {} already exists — skipped", q.name),
5651                            "create_sequence",
5652                        ));
5653                    }
5654                    return Err(RedDBError::Internal(format!(
5655                        "sequence {} already exists",
5656                        q.name
5657                    )));
5658                }
5659                // Persist start + increment, and set current so the first
5660                // nextval returns `start`.
5661                let initial_current = q.start - q.increment;
5662                store.set_config_tree(
5663                    &start_key,
5664                    &crate::serde_json::Value::Number(q.start as f64),
5665                );
5666                store.set_config_tree(
5667                    &incr_key,
5668                    &crate::serde_json::Value::Number(q.increment as f64),
5669                );
5670                store.set_config_tree(
5671                    &curr_key,
5672                    &crate::serde_json::Value::Number(initial_current as f64),
5673                );
5674                Ok(RuntimeQueryResult::ok_message(
5675                    query.to_string(),
5676                    &format!(
5677                        "sequence {} created (start={}, increment={})",
5678                        q.name, q.start, q.increment
5679                    ),
5680                    "create_sequence",
5681                ))
5682            }
5683            QueryExpr::DropSequence(ref q) => {
5684                let store = self.inner.db.store();
5685                let base = format!("sequence.{}", q.name);
5686                let existed = store.get_config(&format!("{base}.start")).is_some();
5687                if !existed && !q.if_exists {
5688                    return Err(RedDBError::Internal(format!(
5689                        "sequence {} does not exist",
5690                        q.name
5691                    )));
5692                }
5693                for k in ["start", "increment", "current"] {
5694                    store.set_config_tree(&format!("{base}.{k}"), &crate::serde_json::Value::Null);
5695                }
5696                Ok(RuntimeQueryResult::ok_message(
5697                    query.to_string(),
5698                    &format!("sequence {} dropped", q.name),
5699                    "drop_sequence",
5700                ))
5701            }
5702            // Views — CREATE [MATERIALIZED] VIEW (Phase 2.1 PG parity).
5703            //
5704            // The view definition is stored in-memory on RuntimeInner (not
5705            // persisted). SELECTs that reference the view name will substitute
5706            // the stored `QueryExpr` via `resolve_view_reference` during
5707            // planning (same entry point used by table-name resolution).
5708            //
5709            // Materialized views additionally allocate a slot in
5710            // `MaterializedViewCache`; a REFRESH repopulates that slot.
5711            QueryExpr::CreateView(ref q) => {
5712                let mut views = self.inner.views.write();
5713                if views.contains_key(&q.name) && !q.or_replace {
5714                    if q.if_not_exists {
5715                        return Ok(RuntimeQueryResult::ok_message(
5716                            query.to_string(),
5717                            &format!("view {} already exists — skipped", q.name),
5718                            "create_view",
5719                        ));
5720                    }
5721                    return Err(RedDBError::Internal(format!(
5722                        "view {} already exists",
5723                        q.name
5724                    )));
5725                }
5726                views.insert(q.name.clone(), Arc::new(q.clone()));
5727                drop(views);
5728
5729                // Materialized view: register cache slot (data is empty until REFRESH).
5730                if q.materialized {
5731                    use crate::storage::cache::result::{MaterializedViewDef, RefreshPolicy};
5732                    let refresh = match q.refresh_every_ms {
5733                        Some(ms) => RefreshPolicy::Periodic(std::time::Duration::from_millis(ms)),
5734                        None => RefreshPolicy::Manual,
5735                    };
5736                    let dependencies = collect_table_refs(&q.query);
5737                    let def = MaterializedViewDef {
5738                        name: q.name.clone(),
5739                        query: format!("<parsed view {}>", q.name),
5740                        dependencies: dependencies.clone(),
5741                        refresh,
5742                        retention_duration_ms: q.retention_duration_ms,
5743                    };
5744                    self.inner.materialized_views.write().register(def);
5745
5746                    // Issue #593 slice 9a — persist the descriptor to
5747                    // the system catalog so the definition survives a
5748                    // restart. Upsert semantics (delete-then-insert by
5749                    // name) keep the catalog free of duplicate rows
5750                    // across `CREATE OR REPLACE` churn.
5751                    let descriptor =
5752                        crate::runtime::continuous_materialized_view::MaterializedViewDescriptor {
5753                            name: q.name.clone(),
5754                            source_sql: query.to_string(),
5755                            source_collections: dependencies,
5756                            refresh_every_ms: q.refresh_every_ms,
5757                            retention_duration_ms: q.retention_duration_ms,
5758                        };
5759                    let store = self.inner.db.store();
5760                    crate::runtime::continuous_materialized_view::persist_descriptor(
5761                        store.as_ref(),
5762                        &descriptor,
5763                    )?;
5764
5765                    // Issue #594 slice 9b — provision a Table-shaped
5766                    // backing collection named after the view. The
5767                    // rewriter skips materialized views (see
5768                    // `rewrite_view_refs_inner`) so `SELECT FROM v`
5769                    // resolves to this collection directly. Empty
5770                    // until REFRESH wires through it in 9c.
5771                    self.ensure_materialized_view_backing(&q.name)?;
5772                }
5773                // Plan cache may have cached a plan that didn't know about this
5774                // view — invalidate so future references pick up the new binding.
5775                // Result cache gets flushed too: OR REPLACE must not serve a
5776                // prior execution of the obsolete body.
5777                self.invalidate_plan_cache();
5778                self.invalidate_result_cache();
5779
5780                Ok(RuntimeQueryResult::ok_message(
5781                    query.to_string(),
5782                    &format!(
5783                        "{}view {} created",
5784                        if q.materialized { "materialized " } else { "" },
5785                        q.name
5786                    ),
5787                    "create_view",
5788                ))
5789            }
5790            QueryExpr::DropView(ref q) => {
5791                let mut views = self.inner.views.write();
5792                let removed = views.remove(&q.name);
5793                let existed = removed.is_some();
5794                let removed_materialized =
5795                    removed.as_ref().map(|v| v.materialized).unwrap_or(false);
5796                drop(views);
5797                if q.materialized || existed {
5798                    // Try the materialised cache too — silent if absent.
5799                    self.inner.materialized_views.write().remove(&q.name);
5800                    // Issue #593 slice 9a — remove any persisted
5801                    // catalog row. Idempotent: a no-op when the view
5802                    // was never materialized (no row was ever written).
5803                    let store = self.inner.db.store();
5804                    crate::runtime::continuous_materialized_view::remove_by_name(
5805                        store.as_ref(),
5806                        &q.name,
5807                    )?;
5808                }
5809                // Issue #594 slice 9b — drop the backing collection
5810                // that was provisioned at CREATE time. Only mat views
5811                // ever had one; regular views never did.
5812                if removed_materialized || q.materialized {
5813                    self.drop_materialized_view_backing(&q.name)?;
5814                }
5815                // Drop any plan / result cache entries that baked the
5816                // view body into their QueryExpr.
5817                self.invalidate_plan_cache();
5818                self.invalidate_result_cache();
5819                if !existed && !q.if_exists {
5820                    return Err(RedDBError::Internal(format!(
5821                        "view {} does not exist",
5822                        q.name
5823                    )));
5824                }
5825                self.invalidate_plan_cache();
5826                Ok(RuntimeQueryResult::ok_message(
5827                    query.to_string(),
5828                    &format!("view {} dropped", q.name),
5829                    "drop_view",
5830                ))
5831            }
5832            QueryExpr::RefreshMaterializedView(ref q) => {
5833                // Look up the view definition, execute its underlying query,
5834                // and stash the serialized result in the materialised cache.
5835                let view = {
5836                    let views = self.inner.views.read();
5837                    views.get(&q.name).cloned()
5838                };
5839                let view = match view {
5840                    Some(v) => v,
5841                    None => {
5842                        return Err(RedDBError::Internal(format!(
5843                            "view {} does not exist",
5844                            q.name
5845                        )))
5846                    }
5847                };
5848                if !view.materialized {
5849                    return Err(RedDBError::Internal(format!(
5850                        "view {} is not materialized — REFRESH requires \
5851                         CREATE MATERIALIZED VIEW",
5852                        q.name
5853                    )));
5854                }
5855                // Execute the underlying query fresh.
5856                let started = std::time::Instant::now();
5857                let now_ms = std::time::SystemTime::now()
5858                    .duration_since(std::time::UNIX_EPOCH)
5859                    .map(|d| d.as_millis() as u64)
5860                    .unwrap_or(0);
5861                match self.execute_query_expr((*view.query).clone()) {
5862                    Ok(inner_result) => {
5863                        // Issue #595 slice 9c — atomically replace the
5864                        // backing collection's contents under a single
5865                        // WAL group. Concurrent SELECT from the view
5866                        // sees either the prior or new contents, never
5867                        // partial. A crash before the WAL commit lands
5868                        // leaves the prior contents intact on recovery.
5869                        let entities =
5870                            view_records_to_entities(&q.name, &inner_result.result.records);
5871                        let row_count = entities.len() as u64;
5872                        let store = self.inner.db.store();
5873                        let serialized_records = match store.refresh_collection(&q.name, entities) {
5874                            Ok(records) => records,
5875                            Err(err) => {
5876                                let duration_ms = started.elapsed().as_millis() as u64;
5877                                let msg = err.to_string();
5878                                self.inner
5879                                    .materialized_views
5880                                    .write()
5881                                    .record_refresh_failure(
5882                                        &q.name,
5883                                        msg.clone(),
5884                                        duration_ms,
5885                                        now_ms,
5886                                    );
5887                                return Err(RedDBError::Internal(format!(
5888                                    "REFRESH MATERIALIZED VIEW {}: {msg}",
5889                                    q.name
5890                                )));
5891                            }
5892                        };
5893
5894                        // Issue #596 slice 9d — emit a Refresh
5895                        // ChangeRecord into the logical-WAL spool so
5896                        // replicas deterministically replay the same
5897                        // backing-collection contents via
5898                        // `LogicalChangeApplier::apply_record`.
5899                        if let Some(ref primary) = self.inner.db.replication {
5900                            let lsn = self.inner.cdc.emit(
5901                                crate::replication::cdc::ChangeOperation::Refresh,
5902                                &q.name,
5903                                0,
5904                                "refresh",
5905                            );
5906                            self.invalidate_result_cache_for_table(&q.name);
5907                            let timestamp = std::time::SystemTime::now()
5908                                .duration_since(std::time::UNIX_EPOCH)
5909                                .unwrap_or_default()
5910                                .as_millis() as u64;
5911                            let record = ChangeRecord::for_refresh(
5912                                lsn,
5913                                timestamp,
5914                                q.name.clone(),
5915                                serialized_records,
5916                            )
5917                            .with_term(self.current_replication_term());
5918                            let encoded = record.encode();
5919                            primary.append_logical_record(record.lsn, encoded);
5920                        }
5921
5922                        let duration_ms = started.elapsed().as_millis() as u64;
5923                        let serialized = format!("{:?}", inner_result.result);
5924                        self.inner
5925                            .materialized_views
5926                            .write()
5927                            .record_refresh_success(
5928                                &q.name,
5929                                serialized.into_bytes(),
5930                                row_count,
5931                                duration_ms,
5932                                now_ms,
5933                            );
5934                        // SELECT FROM v now reads through the rewriter
5935                        // skip into the backing collection — drop the
5936                        // result cache so prior empty-backing reads
5937                        // don't shadow the new contents.
5938                        self.invalidate_result_cache();
5939                        Ok(RuntimeQueryResult::ok_message(
5940                            query.to_string(),
5941                            &format!("materialized view {} refreshed", q.name),
5942                            "refresh_materialized_view",
5943                        ))
5944                    }
5945                    Err(err) => {
5946                        let duration_ms = started.elapsed().as_millis() as u64;
5947                        let msg = err.to_string();
5948                        self.inner
5949                            .materialized_views
5950                            .write()
5951                            .record_refresh_failure(&q.name, msg.clone(), duration_ms, now_ms);
5952                        Err(err)
5953                    }
5954                }
5955            }
5956            // Row Level Security (Phase 2.5 PG parity).
5957            //
5958            // Policies live in an in-memory registry keyed by (table, name).
5959            // Enforcement (AND-ing the policy's USING clause into every
5960            // query's WHERE for the table) arrives in Phase 2.5.2 via the
5961            // filter compiler; this dispatch only manages the catalog.
5962            QueryExpr::CreatePolicy(ref q) => {
5963                let key = (q.table.clone(), q.name.clone());
5964                self.inner
5965                    .rls_policies
5966                    .write()
5967                    .insert(key, Arc::new(q.clone()));
5968                self.invalidate_plan_cache();
5969                // Issue #120 — surface policy names in the
5970                // schema-vocabulary so AskPipeline (#121) can resolve
5971                // a policy reference back to its table.
5972                self.schema_vocabulary_apply(
5973                    crate::runtime::schema_vocabulary::DdlEvent::CreatePolicy {
5974                        collection: q.table.clone(),
5975                        policy: q.name.clone(),
5976                    },
5977                );
5978                Ok(RuntimeQueryResult::ok_message(
5979                    query.to_string(),
5980                    &format!("policy {} on {} created", q.name, q.table),
5981                    "create_policy",
5982                ))
5983            }
5984            QueryExpr::DropPolicy(ref q) => {
5985                let removed = self
5986                    .inner
5987                    .rls_policies
5988                    .write()
5989                    .remove(&(q.table.clone(), q.name.clone()))
5990                    .is_some();
5991                if !removed && !q.if_exists {
5992                    return Err(RedDBError::Internal(format!(
5993                        "policy {} on {} does not exist",
5994                        q.name, q.table
5995                    )));
5996                }
5997                self.invalidate_plan_cache();
5998                // Issue #120 — keep the schema-vocabulary policy
5999                // entry in sync.
6000                self.schema_vocabulary_apply(
6001                    crate::runtime::schema_vocabulary::DdlEvent::DropPolicy {
6002                        collection: q.table.clone(),
6003                        policy: q.name.clone(),
6004                    },
6005                );
6006                Ok(RuntimeQueryResult::ok_message(
6007                    query.to_string(),
6008                    &format!("policy {} on {} dropped", q.name, q.table),
6009                    "drop_policy",
6010                ))
6011            }
6012            // Foreign Data Wrappers (Phase 3.2 PG parity).
6013            //
6014            // CREATE SERVER / CREATE FOREIGN TABLE register into the shared
6015            // `ForeignTableRegistry`. The read path consults that registry
6016            // before dispatching a SELECT — when the table name matches a
6017            // registered foreign table, we forward the scan to the wrapper
6018            // and skip the normal collection lookup.
6019            //
6020            // Phase 3.2 is in-memory only; persistence across restarts is a
6021            // 3.2.2 follow-up that mirrors the view registry pattern.
6022            QueryExpr::CreateServer(ref q) => {
6023                use crate::storage::fdw::FdwOptions;
6024                let registry = Arc::clone(&self.inner.foreign_tables);
6025                if registry.server(&q.name).is_some() {
6026                    if q.if_not_exists {
6027                        return Ok(RuntimeQueryResult::ok_message(
6028                            query.to_string(),
6029                            &format!("server {} already exists — skipped", q.name),
6030                            "create_server",
6031                        ));
6032                    }
6033                    return Err(RedDBError::Internal(format!(
6034                        "server {} already exists",
6035                        q.name
6036                    )));
6037                }
6038                let mut opts = FdwOptions::new();
6039                for (k, v) in &q.options {
6040                    opts.values.insert(k.clone(), v.clone());
6041                }
6042                registry
6043                    .create_server(&q.name, &q.wrapper, opts)
6044                    .map_err(|e| RedDBError::Internal(e.to_string()))?;
6045                Ok(RuntimeQueryResult::ok_message(
6046                    query.to_string(),
6047                    &format!("server {} created (wrapper {})", q.name, q.wrapper),
6048                    "create_server",
6049                ))
6050            }
6051            QueryExpr::DropServer(ref q) => {
6052                let existed = self.inner.foreign_tables.drop_server(&q.name);
6053                if !existed && !q.if_exists {
6054                    return Err(RedDBError::Internal(format!(
6055                        "server {} does not exist",
6056                        q.name
6057                    )));
6058                }
6059                Ok(RuntimeQueryResult::ok_message(
6060                    query.to_string(),
6061                    &format!(
6062                        "server {} dropped{}",
6063                        q.name,
6064                        if q.cascade { " (cascade)" } else { "" }
6065                    ),
6066                    "drop_server",
6067                ))
6068            }
6069            QueryExpr::CreateForeignTable(ref q) => {
6070                use crate::storage::fdw::{FdwOptions, ForeignColumn, ForeignTable};
6071                let registry = Arc::clone(&self.inner.foreign_tables);
6072                if registry.foreign_table(&q.name).is_some() {
6073                    if q.if_not_exists {
6074                        return Ok(RuntimeQueryResult::ok_message(
6075                            query.to_string(),
6076                            &format!("foreign table {} already exists — skipped", q.name),
6077                            "create_foreign_table",
6078                        ));
6079                    }
6080                    return Err(RedDBError::Internal(format!(
6081                        "foreign table {} already exists",
6082                        q.name
6083                    )));
6084                }
6085                let mut opts = FdwOptions::new();
6086                for (k, v) in &q.options {
6087                    opts.values.insert(k.clone(), v.clone());
6088                }
6089                let columns: Vec<ForeignColumn> = q
6090                    .columns
6091                    .iter()
6092                    .map(|c| ForeignColumn {
6093                        name: c.name.clone(),
6094                        data_type: c.data_type.clone(),
6095                        not_null: c.not_null,
6096                    })
6097                    .collect();
6098                registry
6099                    .create_foreign_table(ForeignTable {
6100                        name: q.name.clone(),
6101                        server_name: q.server.clone(),
6102                        columns,
6103                        options: opts,
6104                    })
6105                    .map_err(|e| RedDBError::Internal(e.to_string()))?;
6106                self.invalidate_plan_cache();
6107                Ok(RuntimeQueryResult::ok_message(
6108                    query.to_string(),
6109                    &format!("foreign table {} created (server {})", q.name, q.server),
6110                    "create_foreign_table",
6111                ))
6112            }
6113            QueryExpr::DropForeignTable(ref q) => {
6114                let existed = self.inner.foreign_tables.drop_foreign_table(&q.name);
6115                if !existed && !q.if_exists {
6116                    return Err(RedDBError::Internal(format!(
6117                        "foreign table {} does not exist",
6118                        q.name
6119                    )));
6120                }
6121                self.invalidate_plan_cache();
6122                Ok(RuntimeQueryResult::ok_message(
6123                    query.to_string(),
6124                    &format!("foreign table {} dropped", q.name),
6125                    "drop_foreign_table",
6126                ))
6127            }
6128            // COPY table FROM 'path' (Phase 1.5 PG parity).
6129            //
6130            // Stream CSV rows through the shared `CsvImporter`. The collection
6131            // is auto-created on first insert (via `insert_auto`-style path);
6132            // VACUUM/ANALYZE afterwards is up to the caller.
6133            QueryExpr::CopyFrom(ref q) => {
6134                use crate::storage::import::{CsvConfig, CsvImporter};
6135                let store = self.inner.db.store();
6136                let cfg = CsvConfig {
6137                    collection: q.table.clone(),
6138                    has_header: q.has_header,
6139                    delimiter: q.delimiter.map(|c| c as u8).unwrap_or(b','),
6140                    ..CsvConfig::default()
6141                };
6142                let importer = CsvImporter::new(cfg);
6143                let stats = importer
6144                    .import_file(&q.path, store.as_ref())
6145                    .map_err(|e| RedDBError::Internal(format!("COPY failed: {e}")))?;
6146                // Tables are written → invalidate cached plans / result cache.
6147                self.note_table_write(&q.table);
6148                Ok(RuntimeQueryResult::ok_message(
6149                    query.to_string(),
6150                    &format!(
6151                        "COPY imported {} rows into {} ({} errors skipped, {}ms)",
6152                        stats.records_imported, q.table, stats.errors_skipped, stats.duration_ms
6153                    ),
6154                    "copy_from",
6155                ))
6156            }
6157            // Maintenance commands (Phase 1.2 PG parity).
6158            //
6159            // - VACUUM [FULL] [table]: refreshes planner stats for the target
6160            //   collection(s) and — when FULL — triggers a full pager persist
6161            //   (flushes dirty pages + fsync). Also invalidates the result cache
6162            //   so subsequent reads re-execute against the freshly compacted
6163            //   storage. RedDB's segment/btree GC runs continuously via the
6164            //   background lifecycle; explicit space reclamation for sealed
6165            //   segments arrives with Phase 2.3 (MVCC + dead-tuple reclamation).
6166            // - ANALYZE [table]: reruns `analyze_collection` +
6167            //   `persist_table_stats` via `refresh_table_planner_stats` so the
6168            //   planner has fresh histograms, distinct estimates, null counts.
6169            //
6170            // Both commands accept an optional target; omitting the target
6171            // iterates every collection in the store.
6172            QueryExpr::MaintenanceCommand(ref cmd) => {
6173                use crate::storage::query::ast::MaintenanceCommand as Mc;
6174                let store = self.inner.db.store();
6175                let (kind, msg) = match cmd {
6176                    Mc::Analyze { target } => {
6177                        let targets: Vec<String> = match target {
6178                            Some(t) => vec![t.clone()],
6179                            None => store.list_collections(),
6180                        };
6181                        for t in &targets {
6182                            self.refresh_table_planner_stats(t);
6183                        }
6184                        (
6185                            "analyze",
6186                            format!("ANALYZE refreshed stats for {} table(s)", targets.len()),
6187                        )
6188                    }
6189                    Mc::Vacuum { target, full } => {
6190                        let targets: Vec<String> = match target {
6191                            Some(t) => vec![t.clone()],
6192                            None => store.list_collections(),
6193                        };
6194                        let cutoff_xid = self.mvcc_vacuum_cutoff_xid();
6195                        let mut vacuum_stats =
6196                            crate::storage::unified::store::MvccVacuumStats::default();
6197                        for t in &targets {
6198                            let stats = store.vacuum_mvcc_history(t, cutoff_xid).map_err(|e| {
6199                                RedDBError::Internal(format!(
6200                                    "VACUUM MVCC history failed for {t}: {e}"
6201                                ))
6202                            })?;
6203                            if stats.reclaimed_versions > 0 {
6204                                self.rebuild_runtime_indexes_for_table(t)?;
6205                            }
6206                            vacuum_stats.add(&stats);
6207                        }
6208                        self.inner.snapshot_manager.prune_aborted(cutoff_xid);
6209                        // Stats refresh covers every target (same as ANALYZE).
6210                        for t in &targets {
6211                            self.refresh_table_planner_stats(t);
6212                        }
6213                        // FULL forces a pager persist (dirty-page flush + fsync).
6214                        // Regular VACUUM relies on the background writer / segment
6215                        // lifecycle so the command is non-blocking.
6216                        let persisted = if *full {
6217                            match store.persist() {
6218                                Ok(()) => true,
6219                                Err(e) => {
6220                                    return Err(RedDBError::Internal(format!(
6221                                        "VACUUM FULL persist failed: {e:?}"
6222                                    )));
6223                                }
6224                            }
6225                        } else {
6226                            false
6227                        };
6228                        // Result cache depended on pre-vacuum state.
6229                        self.invalidate_result_cache();
6230                        (
6231                            "vacuum",
6232                            format!(
6233                                "VACUUM{} processed {} table(s): scanned_versions={}, retained_versions={}, reclaimed_versions={}, retained_history_versions={}, reclaimed_history_versions={}, retained_tombstones={}, reclaimed_tombstones={}{}",
6234                                if *full { " FULL" } else { "" },
6235                                targets.len(),
6236                                vacuum_stats.scanned_versions,
6237                                vacuum_stats.retained_versions,
6238                                vacuum_stats.reclaimed_versions,
6239                                vacuum_stats.retained_history_versions,
6240                                vacuum_stats.reclaimed_history_versions,
6241                                vacuum_stats.retained_tombstones,
6242                                vacuum_stats.reclaimed_tombstones,
6243                                if persisted {
6244                                    " (pages flushed to disk)"
6245                                } else {
6246                                    ""
6247                                }
6248                            ),
6249                        )
6250                    }
6251                };
6252                Ok(RuntimeQueryResult::ok_message(
6253                    query.to_string(),
6254                    &msg,
6255                    kind,
6256                ))
6257            }
6258            // GRANT / REVOKE / ALTER USER (RBAC milestone).
6259            //
6260            // These hit the AuthStore directly. The statement frame /
6261            // privilege gate has already decided whether the caller may
6262            // even run the statement; here we just translate the AST into
6263            // AuthStore calls.
6264            QueryExpr::Grant(ref g) => self.execute_grant_statement(query, g),
6265            QueryExpr::Revoke(ref r) => self.execute_revoke_statement(query, r),
6266            QueryExpr::AlterUser(ref a) => self.execute_alter_user_statement(query, a),
6267            QueryExpr::CreateUser(ref u) => self.execute_create_user_statement(query, u),
6268            QueryExpr::CreateIamPolicy { ref id, ref json } => {
6269                self.execute_create_iam_policy(query, id, json)
6270            }
6271            QueryExpr::DropIamPolicy { ref id } => self.execute_drop_iam_policy(query, id),
6272            QueryExpr::AttachPolicy {
6273                ref policy_id,
6274                ref principal,
6275            } => self.execute_attach_policy(query, policy_id, principal),
6276            QueryExpr::DetachPolicy {
6277                ref policy_id,
6278                ref principal,
6279            } => self.execute_detach_policy(query, policy_id, principal),
6280            QueryExpr::ShowPolicies { ref filter } => {
6281                self.execute_show_policies(query, filter.as_ref())
6282            }
6283            QueryExpr::ShowEffectivePermissions {
6284                ref user,
6285                ref resource,
6286            } => self.execute_show_effective_permissions(query, user, resource.as_ref()),
6287            QueryExpr::SimulatePolicy {
6288                ref user,
6289                ref action,
6290                ref resource,
6291            } => self.execute_simulate_policy(query, user, action, resource),
6292            QueryExpr::LintPolicy { ref source } => self.execute_lint_policy(query, source),
6293            QueryExpr::MigratePolicyMode {
6294                ref target,
6295                dry_run,
6296            } => self.execute_migrate_policy_mode(query, target, dry_run),
6297            QueryExpr::CreateMigration(ref q) => self.execute_create_migration(query, q),
6298            QueryExpr::ApplyMigration(ref q) => self.execute_apply_migration(query, q),
6299            QueryExpr::RollbackMigration(ref q) => self.execute_rollback_migration(query, q),
6300            QueryExpr::ExplainMigration(ref q) => self.execute_explain_migration(query, q),
6301        };
6302
6303        if !control_event_specs.is_empty() {
6304            let (outcome, reason) = match &query_result {
6305                Ok(_) => (crate::runtime::control_events::Outcome::Allowed, None),
6306                Err(err) => (control_event_outcome_for_error(err), Some(err.to_string())),
6307            };
6308            for spec in &control_event_specs {
6309                self.emit_control_event(
6310                    spec.kind,
6311                    outcome,
6312                    spec.action,
6313                    spec.resource.clone(),
6314                    reason.clone(),
6315                    spec.fields.clone(),
6316                )?;
6317            }
6318        }
6319
6320        if let (Some(plan), Ok(result)) = (&query_audit_plan, &query_result) {
6321            self.emit_query_audit(
6322                query,
6323                plan,
6324                query_audit_started.elapsed().as_millis() as u64,
6325                result,
6326            );
6327        }
6328
6329        // Decrypt Value::Secret columns in-place before caching, so
6330        // cached results match the post-decrypt shape and repeat
6331        // queries skip the per-row AES-GCM pass.
6332        let mut query_result = query_result;
6333        if let Ok(ref mut result) = query_result {
6334            if result.statement_type == "select" {
6335                self.apply_secret_decryption(result);
6336            }
6337        }
6338
6339        // Cache SELECT results for 30s.
6340        // Skip: pre-serialized JSON (large clone), and result sets > 5 rows.
6341        // Large multi-row results (range scans, filtered scans) are rarely
6342        // repeated with the same literal values so the cache hit rate is near
6343        // zero while the clone cost (100 records × ~16 fields each) is high.
6344        // Aggregations (1 row) and point lookups (1 row) still benefit.
6345        if let Ok(ref result) = query_result {
6346            frame.write_result_cache(self, result, result_cache_scopes);
6347        }
6348
6349        query_result
6350    }
6351
6352    /// Snapshot of every registered materialized view's runtime
6353    /// state — feeds the `red.materialized_views` virtual table.
6354    /// Issue #583 slice 10.
6355    pub fn materialized_view_metadata(
6356        &self,
6357    ) -> Vec<crate::storage::cache::result::MaterializedViewMetadata> {
6358        // Issue #595 slice 9c — `current_row_count` is now scraped
6359        // live from the backing collection rather than read from the
6360        // cache slot. Mirrors the slice-10 invariant on
6361        // `queue_pending_gauge` in #527: the live store is the source
6362        // of truth, the cache slot only carries last-refresh telemetry
6363        // (timing, error, refresh cadence).
6364        let store = self.inner.db.store();
6365        let mut entries = self.inner.materialized_views.read().metadata();
6366        for entry in &mut entries {
6367            if let Some(manager) = store.get_collection(&entry.name) {
6368                entry.current_row_count = manager.count() as u64;
6369            }
6370        }
6371        entries
6372    }
6373
6374    /// Drive scheduled refreshes for materialized views with a
6375    /// `REFRESH EVERY <duration>` clause. Called from the background
6376    /// scheduler thread (and from unit tests with a fake clock via
6377    /// `claim_due_at`). Each invocation atomically claims the set of
6378    /// due views (so two concurrent ticks never double-fire the same
6379    /// view) and runs each refresh through the standard execution
6380    /// path — failures are captured in `last_error` and the prior
6381    /// content stays intact. Issue #583 slice 10.
6382    /// Snapshot of every tracked retention sweeper state — feeds the
6383    /// three extra columns on `red.retention`. Issue #584 slice 12.
6384    pub(crate) fn retention_sweeper_snapshot(
6385        &self,
6386    ) -> Vec<(String, crate::runtime::retention_sweeper::SweeperState)> {
6387        self.inner.retention_sweeper.read().snapshot()
6388    }
6389
6390    /// Drive one tick of the retention sweeper. Iterates collections
6391    /// with a retention policy set, physically deletes at most
6392    /// `batch_size` expired rows per collection, and records the
6393    /// `last_sweep_at_ms` / `rows_swept_total` / pending estimate that
6394    /// `red.retention` exposes. Called from the background sweeper
6395    /// thread; safe to invoke directly from tests with a small batch
6396    /// size to drain rows deterministically. Issue #584 slice 12.
6397    ///
6398    /// Deletes are issued as `DELETE FROM <collection> WHERE
6399    /// <ts_column> < <cutoff>` through the standard `execute_query`
6400    /// chokepoint so WAL participation and snapshot guards apply
6401    /// exactly as for a user-issued DELETE — replicas replay the
6402    /// sweeper's deletes via the same WAL stream with no special
6403    /// handling on the replication side.
6404    ///
6405    /// Batching is enforced by tightening the cutoff: if more than
6406    /// `batch_size` rows are expired, the cutoff is dropped to the
6407    /// `batch_size`-th oldest expired timestamp + 1 so the predicate
6408    /// matches roughly `batch_size` rows; the remainder is reported
6409    /// as `current_rows_pending_sweep_estimate` and drained on the
6410    /// next tick.
6411    pub fn sweep_retention_tick(&self, batch_size: usize) {
6412        if batch_size == 0 {
6413            return;
6414        }
6415        let now_ms = std::time::SystemTime::now()
6416            .duration_since(std::time::UNIX_EPOCH)
6417            .map(|d| d.as_millis() as u64)
6418            .unwrap_or(0);
6419
6420        let store = self.inner.db.store();
6421        let collections = store.list_collections();
6422        for name in collections {
6423            let Some(contract) = self.inner.db.collection_contract(&name) else {
6424                continue;
6425            };
6426            let Some(retention_ms) = contract.retention_duration_ms else {
6427                continue;
6428            };
6429            let Some(ts_column) =
6430                crate::runtime::retention_filter::resolve_timestamp_column(&contract)
6431            else {
6432                continue;
6433            };
6434            let Some(manager) = store.get_collection(&name) else {
6435                continue;
6436            };
6437            let cutoff = (now_ms as i64).saturating_sub(retention_ms as i64);
6438
6439            // Single pass: collect expired timestamps. We keep the
6440            // full Vec rather than a bounded heap because the partial
6441            // sort below is the simplest correct way to find the
6442            // batch-th oldest; for the slice's "1000-row default
6443            // batch" target this is bounded enough for production
6444            // operation, and the alternative (in-place heap of size
6445            // batch+1) is a follow-up optimisation.
6446            let mut expired_ts: Vec<i64> = Vec::new();
6447            manager.for_each_entity(|entity| {
6448                let ts = match ts_column.as_str() {
6449                    "created_at" => Some(entity.created_at as i64),
6450                    "updated_at" => Some(entity.updated_at as i64),
6451                    other => entity
6452                        .data
6453                        .as_row()
6454                        .and_then(|row| row.get_field(other))
6455                        .and_then(|v| match v {
6456                            crate::storage::schema::Value::TimestampMs(t) => Some(*t),
6457                            crate::storage::schema::Value::Timestamp(t) => {
6458                                Some(t.saturating_mul(1_000))
6459                            }
6460                            crate::storage::schema::Value::BigInt(t) => Some(*t),
6461                            crate::storage::schema::Value::UnsignedInteger(t) => {
6462                                i64::try_from(*t).ok()
6463                            }
6464                            crate::storage::schema::Value::Integer(t) => Some(*t),
6465                            _ => None,
6466                        }),
6467                };
6468                if let Some(t) = ts {
6469                    if t < cutoff {
6470                        expired_ts.push(t);
6471                    }
6472                }
6473                true
6474            });
6475
6476            let total_expired = expired_ts.len() as u64;
6477            if total_expired == 0 {
6478                self.inner
6479                    .retention_sweeper
6480                    .write()
6481                    .record_tick(&name, 0, 0, now_ms);
6482                continue;
6483            }
6484
6485            let (effective_cutoff, pending) = if (total_expired as usize) <= batch_size {
6486                (cutoff, 0u64)
6487            } else {
6488                // Tighten the cutoff to the (batch_size)-th oldest
6489                // expired timestamp + 1 so DELETE matches roughly
6490                // `batch_size` rows.
6491                expired_ts.sort_unstable();
6492                let nth = expired_ts[batch_size - 1];
6493                (
6494                    nth.saturating_add(1),
6495                    total_expired.saturating_sub(batch_size as u64),
6496                )
6497            };
6498
6499            let stmt = format!(
6500                "DELETE FROM {} WHERE {} < {}",
6501                name, ts_column, effective_cutoff
6502            );
6503            let deleted = match self.execute_query(&stmt) {
6504                Ok(r) => r.affected_rows,
6505                Err(_) => 0,
6506            };
6507
6508            self.inner
6509                .retention_sweeper
6510                .write()
6511                .record_tick(&name, deleted, pending, now_ms);
6512        }
6513    }
6514
6515    pub fn refresh_due_materialized_views(&self) {
6516        let due = {
6517            let mut cache = self.inner.materialized_views.write();
6518            cache.claim_due_at(std::time::Instant::now())
6519        };
6520        for name in due {
6521            // Round-trip through `execute_query` (rather than the
6522            // prepared-statement `execute_query_expr` fast path, which
6523            // explicitly rejects DDL/maintenance statements). Failures
6524            // are captured inside the RefreshMaterializedView handler
6525            // via `record_refresh_failure`; the scheduler ignores the
6526            // Result so one bad view doesn't halt the loop.
6527            let stmt = format!("REFRESH MATERIALIZED VIEW {}", name);
6528            let _ = self.execute_query(&stmt);
6529        }
6530    }
6531
6532    /// Execute a pre-parsed `QueryExpr` directly, bypassing SQL parsing and the
6533    /// plan cache. Used by the prepared-statement fast path so that `execute_prepared`
6534    /// calls pay zero parse + cache overhead.
6535    ///
6536    /// Applies secret decryption on SELECT results, identical to `execute_query`.
6537    pub fn execute_query_expr(&self, expr: QueryExpr) -> RedDBResult<RuntimeQueryResult> {
6538        let _config_snapshot_guard = ConfigSnapshotGuard::install(Arc::clone(&self.inner.db));
6539        let _secret_store_guard = SecretStoreGuard::install(self.inner.auth_store.read().clone());
6540        // View rewrite (Phase 2.1): substitute any `QueryExpr::Table(tq)`
6541        // whose `tq.table` matches a registered view with the view's
6542        // underlying query. Safe to call even when no views are registered.
6543        let expr = self.rewrite_view_refs(expr);
6544
6545        self.validate_model_operations_before_auth(&expr)?;
6546        // Granular RBAC privilege check. Runs before dispatch so a
6547        // denied caller never reaches storage. Fail-closed: any error
6548        // resolving the action / resource produces PermissionDenied.
6549        if let Err(err) = self.check_query_privilege(&expr) {
6550            return Err(RedDBError::Query(format!("permission denied: {err}")));
6551        }
6552
6553        let statement = query_expr_name(&expr);
6554        let mode = detect_mode(statement);
6555        let query_str = statement;
6556
6557        let result = self.dispatch_expr(expr, query_str, mode)?;
6558        let mut r = result;
6559        if r.statement_type == "select" {
6560            self.apply_secret_decryption(&mut r);
6561        }
6562        Ok(r)
6563    }
6564
6565    pub(super) fn validate_model_operations_before_auth(
6566        &self,
6567        expr: &QueryExpr,
6568    ) -> RedDBResult<()> {
6569        use crate::catalog::CollectionModel;
6570        use crate::runtime::ddl::polymorphic_resolver;
6571        use crate::storage::query::ast::KvCommand;
6572
6573        let system_schema_target = match expr {
6574            QueryExpr::DropTable(q) => Some(q.name.as_str()),
6575            QueryExpr::DropGraph(q) => Some(q.name.as_str()),
6576            QueryExpr::DropVector(q) => Some(q.name.as_str()),
6577            QueryExpr::DropDocument(q) => Some(q.name.as_str()),
6578            QueryExpr::DropKv(q) => Some(q.name.as_str()),
6579            QueryExpr::DropCollection(q) => Some(q.name.as_str()),
6580            QueryExpr::Truncate(q) => Some(q.name.as_str()),
6581            _ => None,
6582        };
6583        if system_schema_target.is_some_and(crate::runtime::impl_ddl::is_system_schema_name) {
6584            return Err(RedDBError::Query("system schema is read-only".to_string()));
6585        }
6586
6587        let expected = match expr {
6588            QueryExpr::DropTable(q) => Some((q.name.as_str(), CollectionModel::Table)),
6589            QueryExpr::DropGraph(q) => Some((q.name.as_str(), CollectionModel::Graph)),
6590            QueryExpr::DropVector(q) => Some((q.name.as_str(), CollectionModel::Vector)),
6591            QueryExpr::DropDocument(q) => Some((q.name.as_str(), CollectionModel::Document)),
6592            QueryExpr::DropKv(q) => Some((q.name.as_str(), q.model)),
6593            QueryExpr::DropCollection(q) => q.model.map(|model| (q.name.as_str(), model)),
6594            QueryExpr::Truncate(q) => q.model.map(|model| (q.name.as_str(), model)),
6595            QueryExpr::KvCommand(cmd) => {
6596                let (collection, model) = match cmd {
6597                    KvCommand::Put {
6598                        collection, model, ..
6599                    }
6600                    | KvCommand::Get {
6601                        collection, model, ..
6602                    }
6603                    | KvCommand::Incr {
6604                        collection, model, ..
6605                    }
6606                    | KvCommand::Cas {
6607                        collection, model, ..
6608                    }
6609                    | KvCommand::List {
6610                        collection, model, ..
6611                    }
6612                    | KvCommand::Delete {
6613                        collection, model, ..
6614                    } => (collection.as_str(), *model),
6615                    KvCommand::Rotate { collection, .. }
6616                    | KvCommand::History { collection, .. }
6617                    | KvCommand::Purge { collection, .. } => {
6618                        (collection.as_str(), CollectionModel::Vault)
6619                    }
6620                    KvCommand::InvalidateTags { collection, .. } => {
6621                        (collection.as_str(), CollectionModel::Kv)
6622                    }
6623                    KvCommand::Watch {
6624                        collection, model, ..
6625                    } => (collection.as_str(), *model),
6626                    KvCommand::Unseal { collection, .. } => {
6627                        (collection.as_str(), CollectionModel::Vault)
6628                    }
6629                };
6630                Some((collection, model))
6631            }
6632            QueryExpr::ConfigCommand(cmd) => {
6633                self.validate_config_command_before_auth(cmd)?;
6634                None
6635            }
6636            _ => None,
6637        };
6638
6639        let Some((name, expected_model)) = expected else {
6640            return Ok(());
6641        };
6642        let snapshot = self.inner.db.catalog_model_snapshot();
6643        let Some(actual_model) = snapshot
6644            .collections
6645            .iter()
6646            .find(|collection| collection.name == name)
6647            .map(|collection| collection.declared_model.unwrap_or(collection.model))
6648        else {
6649            return Ok(());
6650        };
6651        polymorphic_resolver::ensure_model_match(expected_model, actual_model)
6652    }
6653
6654    /// Walk a `QueryExpr` and replace `QueryExpr::Table(tq)` nodes whose
6655    /// `tq.table` matches a registered view name with the view's stored
6656    /// body. Recurses through joins so `SELECT ... FROM t JOIN myview ...`
6657    /// resolves correctly. Pure operation — no side effects.
6658    pub(super) fn rewrite_view_refs(&self, expr: QueryExpr) -> QueryExpr {
6659        // Fast path: no views registered → return original expression.
6660        if self.inner.views.read().is_empty() {
6661            return expr;
6662        }
6663        self.rewrite_view_refs_inner(expr)
6664    }
6665
6666    fn rewrite_view_refs_inner(&self, expr: QueryExpr) -> QueryExpr {
6667        use crate::storage::query::ast::{Filter, TableSource};
6668        match expr {
6669            QueryExpr::Table(mut tq) => {
6670                // 1. If the TableSource is a subquery, recurse into it so
6671                //    `SELECT ... FROM (SELECT ... FROM myview) t` expands.
6672                //    The legacy `table` field (set to a synthetic
6673                //    "__subq_NNNN" sentinel) stays as-is so callers that
6674                //    read it keep compiling.
6675                if let Some(TableSource::Subquery(body)) = tq.source.take() {
6676                    tq.source = Some(TableSource::Subquery(Box::new(
6677                        self.rewrite_view_refs_inner(*body),
6678                    )));
6679                    return QueryExpr::Table(tq);
6680                }
6681
6682                // 2. Restore the source field (took it above for match).
6683                // When the source was `None` or `TableSource::Name(_)`, the
6684                // real lookup key is `tq.table` — check the view registry.
6685                let maybe_view = {
6686                    let views = self.inner.views.read();
6687                    views.get(&tq.table).cloned()
6688                };
6689                let Some(view) = maybe_view else {
6690                    return QueryExpr::Table(tq);
6691                };
6692
6693                // Issue #594 slice 9b — materialized views are read
6694                // from their backing collection, not by substituting
6695                // the body. Returning the TableQuery as-is lets the
6696                // normal table-read path resolve `SELECT FROM v`
6697                // against the collection provisioned at CREATE time.
6698                if view.materialized {
6699                    return QueryExpr::Table(tq);
6700                }
6701
6702                // Recurse into the view body — views may reference other
6703                // views. The recursion yields the final QueryExpr we need
6704                // to merge the outer's filter / limit / offset into.
6705                let inner_expr = self.rewrite_view_refs_inner((*view.query).clone());
6706
6707                // Phase 5: when the body is a Table we merge the outer
6708                // TableQuery's WHERE / LIMIT / OFFSET into it so stacked
6709                // views filter recursively. Non-table bodies (Search,
6710                // Ask, Vector, Graph, Hybrid) can't meaningfully combine
6711                // with an outer Table query today — return the body
6712                // verbatim; outer predicates are lost. Full projection
6713                // merge lands in Phase 5.2.
6714                match inner_expr {
6715                    QueryExpr::Table(mut inner_tq) => {
6716                        if let Some(outer_filter) = tq.filter.take() {
6717                            inner_tq.filter = Some(match inner_tq.filter.take() {
6718                                Some(existing) => {
6719                                    Filter::And(Box::new(existing), Box::new(outer_filter))
6720                                }
6721                                None => outer_filter,
6722                            });
6723                            // Keep the `Expr` form in lock-step with the
6724                            // merged `Filter`. The executor prefers
6725                            // `where_expr` and nulls `filter` when it is
6726                            // present (see `execute_query_inner`), so a
6727                            // stacked view whose outer predicate was only
6728                            // merged into `filter` would silently drop that
6729                            // predicate at eval time (#635).
6730                            inner_tq.where_expr = inner_tq
6731                                .filter
6732                                .as_ref()
6733                                .map(crate::storage::query::sql_lowering::filter_to_expr);
6734                        }
6735                        if let Some(outer_limit) = tq.limit {
6736                            inner_tq.limit = Some(match inner_tq.limit {
6737                                Some(existing) => existing.min(outer_limit),
6738                                None => outer_limit,
6739                            });
6740                        }
6741                        if let Some(outer_offset) = tq.offset {
6742                            inner_tq.offset = Some(match inner_tq.offset {
6743                                Some(existing) => existing + outer_offset,
6744                                None => outer_offset,
6745                            });
6746                        }
6747                        QueryExpr::Table(inner_tq)
6748                    }
6749                    other => other,
6750                }
6751            }
6752            QueryExpr::Join(mut jq) => {
6753                jq.left = Box::new(self.rewrite_view_refs_inner(*jq.left));
6754                jq.right = Box::new(self.rewrite_view_refs_inner(*jq.right));
6755                QueryExpr::Join(jq)
6756            }
6757            // Other variants don't carry nested QueryExpr that can reference
6758            // a view by table name. Return as-is.
6759            other => other,
6760        }
6761    }
6762
6763    /// Apply table-level read authorization and RLS rewriting for a
6764    /// relational SELECT leaf.
6765    fn authorize_relational_table_select(
6766        &self,
6767        mut table: TableQuery,
6768        frame: &dyn super::statement_frame::ReadFrame,
6769    ) -> RedDBResult<Option<TableQuery>> {
6770        if let Some(TableSource::Subquery(inner)) = table.source.take() {
6771            let authorized_inner = self.authorize_relational_select_expr(*inner, frame)?;
6772            table.source = Some(TableSource::Subquery(Box::new(authorized_inner)));
6773            return Ok(Some(table));
6774        }
6775
6776        self.check_table_column_projection_authz(&table, frame)?;
6777
6778        if self.inner.rls_enabled_tables.read().contains(&table.table) {
6779            return Ok(inject_rls_filters(self, frame, table));
6780        }
6781
6782        Ok(Some(table))
6783    }
6784
6785    fn authorize_relational_join_select(
6786        &self,
6787        mut join: JoinQuery,
6788        frame: &dyn super::statement_frame::ReadFrame,
6789    ) -> RedDBResult<Option<JoinQuery>> {
6790        self.check_join_column_projection_authz(&join, frame)?;
6791        join.left = Box::new(self.authorize_relational_join_child(*join.left, frame)?);
6792        join.right = Box::new(self.authorize_relational_join_child(*join.right, frame)?);
6793        Ok(inject_rls_into_join(self, frame, join))
6794    }
6795
6796    fn authorize_relational_join_child(
6797        &self,
6798        expr: QueryExpr,
6799        frame: &dyn super::statement_frame::ReadFrame,
6800    ) -> RedDBResult<QueryExpr> {
6801        match expr {
6802            QueryExpr::Table(mut table) => {
6803                if let Some(TableSource::Subquery(inner)) = table.source.take() {
6804                    let authorized_inner = self.authorize_relational_select_expr(*inner, frame)?;
6805                    table.source = Some(TableSource::Subquery(Box::new(authorized_inner)));
6806                }
6807                Ok(QueryExpr::Table(table))
6808            }
6809            QueryExpr::Join(join) => self
6810                .authorize_relational_join_select(join, frame)?
6811                .map(QueryExpr::Join)
6812                .ok_or_else(|| {
6813                    RedDBError::Query("permission denied: RLS denied relational subquery".into())
6814                }),
6815            other => Ok(other),
6816        }
6817    }
6818
6819    fn authorize_relational_select_expr(
6820        &self,
6821        expr: QueryExpr,
6822        frame: &dyn super::statement_frame::ReadFrame,
6823    ) -> RedDBResult<QueryExpr> {
6824        match expr {
6825            QueryExpr::Table(table) => self
6826                .authorize_relational_table_select(table, frame)?
6827                .map(QueryExpr::Table)
6828                .ok_or_else(|| {
6829                    RedDBError::Query("permission denied: RLS denied relational subquery".into())
6830                }),
6831            QueryExpr::Join(join) => self
6832                .authorize_relational_join_select(join, frame)?
6833                .map(QueryExpr::Join)
6834                .ok_or_else(|| {
6835                    RedDBError::Query("permission denied: RLS denied relational subquery".into())
6836                }),
6837            other => Ok(other),
6838        }
6839    }
6840
6841    fn check_table_column_projection_authz(
6842        &self,
6843        table: &TableQuery,
6844        frame: &dyn super::statement_frame::ReadFrame,
6845    ) -> RedDBResult<()> {
6846        let Some((username, role)) = frame.identity() else {
6847            return Ok(());
6848        };
6849        let Some(auth_store) = self.inner.auth_store.read().clone() else {
6850            return Ok(());
6851        };
6852
6853        let columns = self.resolved_table_projection_columns(table)?;
6854        let request = ColumnAccessRequest::select(table.table.clone(), columns);
6855        let principal = UserId::from_parts(frame.effective_scope(), username);
6856        let ctx = runtime_iam_context(role, frame.effective_scope());
6857        let outcome = auth_store.check_column_projection_authz(&principal, &request, &ctx);
6858        if outcome.allowed() {
6859            return Ok(());
6860        }
6861
6862        if let Some(denied) = outcome.first_denied_column() {
6863            return Err(RedDBError::Query(format!(
6864                "permission denied: principal=`{username}` cannot select column `{}`",
6865                denied.resource.name
6866            )));
6867        }
6868        Err(RedDBError::Query(format!(
6869            "permission denied: principal=`{username}` cannot select table `{}`",
6870            table.table
6871        )))
6872    }
6873
6874    fn check_join_column_projection_authz(
6875        &self,
6876        join: &JoinQuery,
6877        frame: &dyn super::statement_frame::ReadFrame,
6878    ) -> RedDBResult<()> {
6879        let mut by_table: HashMap<String, BTreeSet<String>> = HashMap::new();
6880        let projections = crate::storage::query::sql_lowering::effective_join_projections(join);
6881        self.collect_join_projection_columns(join, &projections, &mut by_table)?;
6882
6883        for (table, columns) in by_table {
6884            let query = TableQuery {
6885                table,
6886                source: None,
6887                alias: None,
6888                select_items: Vec::new(),
6889                columns: columns.into_iter().map(Projection::Column).collect(),
6890                where_expr: None,
6891                filter: None,
6892                group_by_exprs: Vec::new(),
6893                group_by: Vec::new(),
6894                having_expr: None,
6895                having: None,
6896                order_by: Vec::new(),
6897                limit: None,
6898                limit_param: None,
6899                offset: None,
6900                offset_param: None,
6901                expand: None,
6902                as_of: None,
6903                sessionize: None,
6904                distinct: false,
6905            };
6906            self.check_table_column_projection_authz(&query, frame)?;
6907        }
6908        Ok(())
6909    }
6910
6911    fn collect_join_projection_columns(
6912        &self,
6913        join: &JoinQuery,
6914        projections: &[Projection],
6915        out: &mut HashMap<String, BTreeSet<String>>,
6916    ) -> RedDBResult<()> {
6917        let left = table_side_context(join.left.as_ref());
6918        let right = table_side_context(join.right.as_ref());
6919
6920        if projections
6921            .iter()
6922            .any(|projection| matches!(projection, Projection::All))
6923        {
6924            for side in [left.as_ref(), right.as_ref()].into_iter().flatten() {
6925                out.entry(side.table.clone())
6926                    .or_default()
6927                    .extend(self.table_all_projection_columns(&side.table)?);
6928            }
6929            return Ok(());
6930        }
6931
6932        for projection in projections {
6933            collect_projection_columns_for_join_side(
6934                projection,
6935                left.as_ref(),
6936                right.as_ref(),
6937                out,
6938            )?;
6939        }
6940        Ok(())
6941    }
6942
6943    fn resolved_table_projection_columns(&self, table: &TableQuery) -> RedDBResult<Vec<String>> {
6944        let projections = crate::storage::query::sql_lowering::effective_table_projections(table);
6945        if projections
6946            .iter()
6947            .any(|projection| matches!(projection, Projection::All))
6948        {
6949            return self.table_all_projection_columns(&table.table);
6950        }
6951
6952        let mut columns = BTreeSet::new();
6953        for projection in &projections {
6954            collect_projection_columns_for_table(
6955                projection,
6956                &table.table,
6957                table.alias.as_deref(),
6958                &mut columns,
6959            );
6960        }
6961        Ok(columns.into_iter().collect())
6962    }
6963
6964    fn table_all_projection_columns(&self, table: &str) -> RedDBResult<Vec<String>> {
6965        if let Some(contract) = self.inner.db.collection_contract_arc(table) {
6966            let columns: Vec<String> = contract
6967                .declared_columns
6968                .iter()
6969                .map(|column| column.name.clone())
6970                .collect();
6971            if !columns.is_empty() {
6972                return Ok(columns);
6973            }
6974        }
6975
6976        let records = scan_runtime_table_source_records_limited(&self.inner.db, table, Some(1))?;
6977        Ok(records
6978            .first()
6979            .map(|record| {
6980                record
6981                    .column_names()
6982                    .into_iter()
6983                    .map(|column| column.to_string())
6984                    .collect()
6985            })
6986            .unwrap_or_default())
6987    }
6988
6989    fn resolve_table_expr_subqueries(
6990        &self,
6991        mut table: TableQuery,
6992        frame: &dyn super::statement_frame::ReadFrame,
6993    ) -> RedDBResult<TableQuery> {
6994        // Only a `Subquery` source needs recursive resolution. `.take()`
6995        // would otherwise drop a `Name` / `Function` source on the floor
6996        // (the `if let` skips the body but the take already cleared it),
6997        // which silently broke `SELECT * FROM components(g)` — the TVF
6998        // dispatch downstream keys off `TableSource::Function` and never
6999        // fired. Restore any non-subquery source unchanged (issue #795).
7000        match table.source.take() {
7001            Some(TableSource::Subquery(inner)) => {
7002                let inner = self.resolve_select_expr_subqueries(*inner, frame)?;
7003                table.source = Some(TableSource::Subquery(Box::new(inner)));
7004            }
7005            other => table.source = other,
7006        }
7007
7008        let outer_scopes = relation_scopes_for_query(&QueryExpr::Table(table.clone()));
7009        for item in &mut table.select_items {
7010            if let crate::storage::query::ast::SelectItem::Expr { expr, .. } = item {
7011                *expr = self.resolve_expr_subqueries(expr.clone(), &outer_scopes, frame)?;
7012            }
7013        }
7014        if let Some(where_expr) = table.where_expr.take() {
7015            table.where_expr =
7016                Some(self.resolve_expr_subqueries(where_expr, &outer_scopes, frame)?);
7017            table.filter = None;
7018        }
7019        if let Some(having_expr) = table.having_expr.take() {
7020            table.having_expr =
7021                Some(self.resolve_expr_subqueries(having_expr, &outer_scopes, frame)?);
7022            table.having = None;
7023        }
7024        for expr in &mut table.group_by_exprs {
7025            *expr = self.resolve_expr_subqueries(expr.clone(), &outer_scopes, frame)?;
7026        }
7027        for clause in &mut table.order_by {
7028            if let Some(expr) = clause.expr.take() {
7029                clause.expr = Some(self.resolve_expr_subqueries(expr, &outer_scopes, frame)?);
7030            }
7031        }
7032        Ok(table)
7033    }
7034
7035    fn resolve_select_expr_subqueries(
7036        &self,
7037        expr: QueryExpr,
7038        frame: &dyn super::statement_frame::ReadFrame,
7039    ) -> RedDBResult<QueryExpr> {
7040        match expr {
7041            QueryExpr::Table(table) => self
7042                .resolve_table_expr_subqueries(table, frame)
7043                .map(QueryExpr::Table),
7044            QueryExpr::Join(mut join) => {
7045                join.left = Box::new(self.resolve_select_expr_subqueries(*join.left, frame)?);
7046                join.right = Box::new(self.resolve_select_expr_subqueries(*join.right, frame)?);
7047                Ok(QueryExpr::Join(join))
7048            }
7049            other => Ok(other),
7050        }
7051    }
7052
7053    fn resolve_expr_subqueries(
7054        &self,
7055        expr: crate::storage::query::ast::Expr,
7056        outer_scopes: &[String],
7057        frame: &dyn super::statement_frame::ReadFrame,
7058    ) -> RedDBResult<crate::storage::query::ast::Expr> {
7059        use crate::storage::query::ast::Expr;
7060
7061        match expr {
7062            Expr::Subquery { query, span } => {
7063                let values = self.execute_expr_subquery_values(query, outer_scopes, frame)?;
7064                if values.len() > 1 {
7065                    return Err(RedDBError::Query(
7066                        "scalar subquery returned more than one row".to_string(),
7067                    ));
7068                }
7069                Ok(Expr::Literal {
7070                    value: values.into_iter().next().unwrap_or(Value::Null),
7071                    span,
7072                })
7073            }
7074            Expr::BinaryOp { op, lhs, rhs, span } => Ok(Expr::BinaryOp {
7075                op,
7076                lhs: Box::new(self.resolve_expr_subqueries(*lhs, outer_scopes, frame)?),
7077                rhs: Box::new(self.resolve_expr_subqueries(*rhs, outer_scopes, frame)?),
7078                span,
7079            }),
7080            Expr::UnaryOp { op, operand, span } => Ok(Expr::UnaryOp {
7081                op,
7082                operand: Box::new(self.resolve_expr_subqueries(*operand, outer_scopes, frame)?),
7083                span,
7084            }),
7085            Expr::Cast {
7086                inner,
7087                target,
7088                span,
7089            } => Ok(Expr::Cast {
7090                inner: Box::new(self.resolve_expr_subqueries(*inner, outer_scopes, frame)?),
7091                target,
7092                span,
7093            }),
7094            Expr::FunctionCall { name, args, span } => {
7095                let args = args
7096                    .into_iter()
7097                    .map(|arg| self.resolve_expr_subqueries(arg, outer_scopes, frame))
7098                    .collect::<RedDBResult<Vec<_>>>()?;
7099                Ok(Expr::FunctionCall { name, args, span })
7100            }
7101            Expr::Case {
7102                branches,
7103                else_,
7104                span,
7105            } => {
7106                let branches = branches
7107                    .into_iter()
7108                    .map(|(cond, value)| {
7109                        Ok((
7110                            self.resolve_expr_subqueries(cond, outer_scopes, frame)?,
7111                            self.resolve_expr_subqueries(value, outer_scopes, frame)?,
7112                        ))
7113                    })
7114                    .collect::<RedDBResult<Vec<_>>>()?;
7115                let else_ = else_
7116                    .map(|expr| self.resolve_expr_subqueries(*expr, outer_scopes, frame))
7117                    .transpose()?
7118                    .map(Box::new);
7119                Ok(Expr::Case {
7120                    branches,
7121                    else_,
7122                    span,
7123                })
7124            }
7125            Expr::IsNull {
7126                operand,
7127                negated,
7128                span,
7129            } => Ok(Expr::IsNull {
7130                operand: Box::new(self.resolve_expr_subqueries(*operand, outer_scopes, frame)?),
7131                negated,
7132                span,
7133            }),
7134            Expr::InList {
7135                target,
7136                values,
7137                negated,
7138                span,
7139            } => {
7140                let target =
7141                    Box::new(self.resolve_expr_subqueries(*target, outer_scopes, frame)?);
7142                let mut resolved = Vec::new();
7143                for value in values {
7144                    if let Expr::Subquery { query, .. } = value {
7145                        resolved.extend(
7146                            self.execute_expr_subquery_values(query, outer_scopes, frame)?
7147                                .into_iter()
7148                                .map(Expr::lit),
7149                        );
7150                    } else {
7151                        resolved.push(self.resolve_expr_subqueries(value, outer_scopes, frame)?);
7152                    }
7153                }
7154                Ok(Expr::InList {
7155                    target,
7156                    values: resolved,
7157                    negated,
7158                    span,
7159                })
7160            }
7161            Expr::Between {
7162                target,
7163                low,
7164                high,
7165                negated,
7166                span,
7167            } => Ok(Expr::Between {
7168                target: Box::new(self.resolve_expr_subqueries(*target, outer_scopes, frame)?),
7169                low: Box::new(self.resolve_expr_subqueries(*low, outer_scopes, frame)?),
7170                high: Box::new(self.resolve_expr_subqueries(*high, outer_scopes, frame)?),
7171                negated,
7172                span,
7173            }),
7174            other => Ok(other),
7175        }
7176    }
7177
7178    fn execute_expr_subquery_values(
7179        &self,
7180        subquery: crate::storage::query::ast::ExprSubquery,
7181        outer_scopes: &[String],
7182        frame: &dyn super::statement_frame::ReadFrame,
7183    ) -> RedDBResult<Vec<Value>> {
7184        let query = *subquery.query;
7185        if query_references_outer_scope(&query, outer_scopes) {
7186            return Err(RedDBError::Query(
7187                "NOT_YET_SUPPORTED: correlated subqueries are not supported yet; track follow-up issue #470-correlated-subqueries".to_string(),
7188            ));
7189        }
7190        let query = self.rewrite_view_refs(query);
7191        let query = self.resolve_select_expr_subqueries(query, frame)?;
7192        let query = self.authorize_relational_select_expr(query, frame)?;
7193        let result = match query {
7194            QueryExpr::Table(table) => {
7195                execute_runtime_table_query(&self.inner.db, &table, Some(&self.inner.index_store))?
7196            }
7197            QueryExpr::Join(join) => execute_runtime_join_query(&self.inner.db, &join)?,
7198            other => {
7199                return Err(RedDBError::Query(format!(
7200                    "expression subquery must be a SELECT query, got {}",
7201                    query_expr_name(&other)
7202                )))
7203            }
7204        };
7205        first_column_values(result)
7206    }
7207
7208    fn dispatch_expr(
7209        &self,
7210        expr: QueryExpr,
7211        query_str: &str,
7212        mode: QueryMode,
7213    ) -> RedDBResult<RuntimeQueryResult> {
7214        let statement = query_expr_name(&expr);
7215        match expr {
7216            QueryExpr::Graph(_) | QueryExpr::Path(_) => {
7217                // Graph queries are not cacheable as prepared statements.
7218                Err(RedDBError::Query(
7219                    "graph queries cannot be used as prepared statements".to_string(),
7220                ))
7221            }
7222            QueryExpr::Table(table) => {
7223                let scope = self.ai_scope();
7224                let table = self.resolve_table_expr_subqueries(
7225                    table,
7226                    &scope as &dyn super::statement_frame::ReadFrame,
7227                )?;
7228                // Table-valued functions (e.g. components(g)) dispatch to a
7229                // read-only executor before any catalog/virtual-table routing
7230                // (issue #795).
7231                if let Some(TableSource::Function {
7232                    name,
7233                    args,
7234                    named_args,
7235                }) = table.source.clone()
7236                {
7237                    return Ok(RuntimeQueryResult {
7238                        query: query_str.to_string(),
7239                        mode,
7240                        statement,
7241                        engine: "runtime-graph-tvf",
7242                        result: self.execute_table_function(&name, &args, &named_args)?,
7243                        affected_rows: 0,
7244                        statement_type: "select",
7245                        bookmark: None,
7246                    });
7247                }
7248                // Inline-graph TVF (issue #799) on the prepared-statement /
7249                // direct-expr path. Result caching is wired on the
7250                // `execute_query_inner` path; here we just compute and return.
7251                if let Some(TableSource::InlineGraphFunction {
7252                    name,
7253                    nodes,
7254                    edges,
7255                    named_args,
7256                }) = table.source.clone()
7257                {
7258                    return Ok(RuntimeQueryResult {
7259                        query: query_str.to_string(),
7260                        mode,
7261                        statement,
7262                        engine: "runtime-graph-tvf-inline",
7263                        result: self.execute_inline_graph_function(
7264                            &name,
7265                            &nodes,
7266                            &edges,
7267                            &named_args,
7268                        )?,
7269                        affected_rows: 0,
7270                        statement_type: "select",
7271                        bookmark: None,
7272                    });
7273                }
7274                if super::red_schema::is_virtual_table(&table.table) {
7275                    return Ok(RuntimeQueryResult {
7276                        query: query_str.to_string(),
7277                        mode,
7278                        statement,
7279                        engine: "runtime-red-schema",
7280                        result: super::red_schema::red_query(
7281                            self,
7282                            &table.table,
7283                            &table,
7284                            &scope as &dyn super::statement_frame::ReadFrame,
7285                        )?,
7286                        affected_rows: 0,
7287                        statement_type: "select",
7288                        bookmark: None,
7289                    });
7290                }
7291                // `<graph>.<output>` analytics virtual view (issue #800).
7292                if let Some(view_result) = self.try_resolve_analytics_view(
7293                    &table,
7294                    &scope as &dyn super::statement_frame::ReadFrame,
7295                )? {
7296                    return Ok(RuntimeQueryResult {
7297                        query: query_str.to_string(),
7298                        mode,
7299                        statement,
7300                        engine: "runtime-graph-analytics-view",
7301                        result: view_result,
7302                        affected_rows: 0,
7303                        statement_type: "select",
7304                        bookmark: None,
7305                    });
7306                }
7307                let Some(table_with_rls) = self.authorize_relational_table_select(
7308                    table,
7309                    &scope as &dyn super::statement_frame::ReadFrame,
7310                )?
7311                else {
7312                    return Ok(RuntimeQueryResult {
7313                        query: query_str.to_string(),
7314                        mode,
7315                        statement,
7316                        engine: "runtime-table-rls",
7317                        result: crate::storage::query::unified::UnifiedResult::empty(),
7318                        affected_rows: 0,
7319                        statement_type: "select",
7320                        bookmark: None,
7321                    });
7322                };
7323                Ok(RuntimeQueryResult {
7324                    query: query_str.to_string(),
7325                    mode,
7326                    statement,
7327                    engine: "runtime-table",
7328                    result: execute_runtime_table_query(
7329                        &self.inner.db,
7330                        &table_with_rls,
7331                        Some(&self.inner.index_store),
7332                    )?,
7333                    affected_rows: 0,
7334                    statement_type: "select",
7335                    bookmark: None,
7336                })
7337            }
7338            QueryExpr::Join(join) => {
7339                let scope = self.ai_scope();
7340                let Some(join_with_rls) = self.authorize_relational_join_select(
7341                    join,
7342                    &scope as &dyn super::statement_frame::ReadFrame,
7343                )?
7344                else {
7345                    return Ok(RuntimeQueryResult {
7346                        query: query_str.to_string(),
7347                        mode,
7348                        statement,
7349                        engine: "runtime-join-rls",
7350                        result: crate::storage::query::unified::UnifiedResult::empty(),
7351                        affected_rows: 0,
7352                        statement_type: "select",
7353                        bookmark: None,
7354                    });
7355                };
7356                Ok(RuntimeQueryResult {
7357                    query: query_str.to_string(),
7358                    mode,
7359                    statement,
7360                    engine: "runtime-join",
7361                    result: execute_runtime_join_query(&self.inner.db, &join_with_rls)?,
7362                    affected_rows: 0,
7363                    statement_type: "select",
7364                    bookmark: None,
7365                })
7366            }
7367            QueryExpr::Vector(vector) => Ok(RuntimeQueryResult {
7368                query: query_str.to_string(),
7369                mode,
7370                statement,
7371                engine: "runtime-vector",
7372                result: execute_runtime_vector_query(&self.inner.db, &vector)?,
7373                affected_rows: 0,
7374                statement_type: "select",
7375                bookmark: None,
7376            }),
7377            QueryExpr::Hybrid(hybrid) => Ok(RuntimeQueryResult {
7378                query: query_str.to_string(),
7379                mode,
7380                statement,
7381                engine: "runtime-hybrid",
7382                result: execute_runtime_hybrid_query(&self.inner.db, &hybrid)?,
7383                affected_rows: 0,
7384                statement_type: "select",
7385                bookmark: None,
7386            }),
7387            QueryExpr::Insert(ref insert) if super::red_schema::is_virtual_table(&insert.table) => {
7388                Err(RedDBError::Query(
7389                    super::red_schema::READ_ONLY_ERROR.to_string(),
7390                ))
7391            }
7392            QueryExpr::Update(ref update) if super::red_schema::is_virtual_table(&update.table) => {
7393                Err(RedDBError::Query(
7394                    super::red_schema::READ_ONLY_ERROR.to_string(),
7395                ))
7396            }
7397            QueryExpr::Delete(ref delete) if super::red_schema::is_virtual_table(&delete.table) => {
7398                Err(RedDBError::Query(
7399                    super::red_schema::READ_ONLY_ERROR.to_string(),
7400                ))
7401            }
7402            QueryExpr::Insert(ref insert) => self
7403                .with_deferred_store_wal_for_dml(self.insert_may_emit_events(insert), || {
7404                    self.execute_insert(query_str, insert)
7405                }),
7406            QueryExpr::Update(ref update) => self
7407                .with_deferred_store_wal_for_dml(self.update_may_emit_events(update), || {
7408                    self.execute_update(query_str, update)
7409                }),
7410            QueryExpr::Delete(ref delete) => self
7411                .with_deferred_store_wal_for_dml(self.delete_may_emit_events(delete), || {
7412                    self.execute_delete(query_str, delete)
7413                }),
7414            QueryExpr::SearchCommand(ref cmd) => self.execute_search_command(query_str, cmd),
7415            QueryExpr::Ask(ref ask) => self.execute_ask(query_str, ask),
7416            _ => Err(RedDBError::Query(format!(
7417                "prepared-statement execution does not support {statement} statements"
7418            ))),
7419        }
7420    }
7421
7422    /// Dispatch a graph-collection table-valued function call in FROM
7423    /// position (e.g. `SELECT * FROM components(g)`).
7424    ///
7425    /// Validates the function name and arity here, materializes the whole
7426    /// active graph read-only, then runs the algorithm via the shared
7427    /// `dispatch_graph_algorithm` path. Never mutates the catalog or store.
7428    fn execute_table_function(
7429        &self,
7430        name: &str,
7431        args: &[String],
7432        named_args: &[(String, f64)],
7433    ) -> RedDBResult<crate::storage::query::unified::UnifiedResult> {
7434        if !is_graph_tvf_name(name) {
7435            return Err(RedDBError::Query(format!("unknown table function: {name}")));
7436        }
7437        // Every graph-collection TVF takes exactly one graph argument.
7438        if args.len() != 1 {
7439            return Err(RedDBError::Query(format!(
7440                "table function '{name}' takes exactly 1 graph argument, got {}",
7441                args.len()
7442            )));
7443        }
7444
7445        // Read-only materialization of the full active graph. Passing `None`
7446        // for the projection uses the full graph store. Like #795/#796, the
7447        // v0 form runs over the whole graph store regardless of the collection
7448        // argument value. Materialization never mutates any store.
7449        let (nodes, edges) = self.materialize_whole_graph_abstract()?;
7450        self.dispatch_graph_algorithm(name, nodes, edges, named_args)
7451    }
7452
7453    /// Dispatch an inline-graph table-valued function call in FROM position
7454    /// (e.g. `SELECT * FROM components(nodes => (…), edges => (…))`, issue
7455    /// #799).
7456    ///
7457    /// Materializes the two subqueries through the normal read path (so RLS,
7458    /// column authz, and MVCC visibility all apply), constructs the abstract
7459    /// graph — the first column of `nodes` is the node id; the first two-or-
7460    /// three columns of `edges` are `(source, target [, weight])` — then runs
7461    /// the same algorithm path used by the graph-collection form. Read-only.
7462    fn execute_inline_graph_function(
7463        &self,
7464        name: &str,
7465        nodes_query: &QueryExpr,
7466        edges_query: &QueryExpr,
7467        named_args: &[(String, f64)],
7468    ) -> RedDBResult<crate::storage::query::unified::UnifiedResult> {
7469        if !is_graph_tvf_name(name) {
7470            return Err(RedDBError::Query(format!("unknown table function: {name}")));
7471        }
7472
7473        let node_result = self.execute_query_expr(nodes_query.clone())?.result;
7474        let nodes = inline_node_ids(name, &node_result)?;
7475
7476        let edge_result = self.execute_query_expr(edges_query.clone())?.result;
7477        let edges = inline_edges(name, &edge_result)?;
7478
7479        self.dispatch_graph_algorithm(name, nodes, edges, named_args)
7480    }
7481
7482    /// Materialize the whole active graph read-only into the abstract
7483    /// `(nodes, edges)` inputs the pure graph algorithms consume.
7484    fn materialize_whole_graph_abstract(
7485        &self,
7486    ) -> RedDBResult<(
7487        Vec<String>,
7488        Vec<(
7489            String,
7490            String,
7491            crate::storage::engine::graph_algorithms::Weight,
7492        )>,
7493    )> {
7494        use crate::storage::engine::graph_algorithms;
7495
7496        let graph = super::graph_dsl::materialize_graph_with_projection(
7497            self.inner.db.store().as_ref(),
7498            None,
7499        )?;
7500        let nodes: Vec<String> = graph.iter_nodes().map(|n| n.id.clone()).collect();
7501        let edges: Vec<(String, String, graph_algorithms::Weight)> = graph
7502            .iter_all_edges()
7503            .into_iter()
7504            .map(|e| (e.source_id, e.target_id, e.weight))
7505            .collect();
7506        Ok((nodes, edges))
7507    }
7508
7509    /// Resolve a `<graph>.<output>` analytics virtual view (issue #800).
7510    ///
7511    /// Returns `Ok(None)` when `table` is not an analytics view — either the
7512    /// name is not dotted, a real collection of that exact name exists (a real
7513    /// collection always wins; no shadowing), the suffix is not a recognised
7514    /// analytics output, or the parent is not a graph. Returns `Ok(Some(_))`
7515    /// with the freshly computed result when it does resolve, and an error when
7516    /// the parent graph exists but the output is not enabled, a declared
7517    /// algorithm is unsupported, or the parent collection's policy denies the
7518    /// read.
7519    ///
7520    /// The view is recomputed on every call (no result-cache write) so it
7521    /// always reflects the current graph data, satisfying the on-demand
7522    /// recompute contract for this slice.
7523    fn try_resolve_analytics_view(
7524        &self,
7525        table: &TableQuery,
7526        frame: &dyn super::statement_frame::ReadFrame,
7527    ) -> RedDBResult<Option<crate::storage::query::unified::UnifiedResult>> {
7528        let full = table.table.as_str();
7529        let Some(dot) = full.rfind('.') else {
7530            return Ok(None);
7531        };
7532        // A real collection literally named `g.communities` always wins.
7533        if self.inner.db.store().get_collection(full).is_some() {
7534            return Ok(None);
7535        }
7536        let graph_name = &full[..dot];
7537        let output_name = &full[dot + 1..];
7538        let Some(output) = crate::catalog::AnalyticsOutput::from_str(output_name) else {
7539            return Ok(None);
7540        };
7541
7542        let contracts = self.inner.db.collection_contracts();
7543        let Some(contract) = contracts.iter().find(|c| c.name == graph_name) else {
7544            return Ok(None);
7545        };
7546        if contract.declared_model != crate::catalog::CollectionModel::Graph {
7547            return Ok(None);
7548        }
7549        let Some(view) = contract
7550            .analytics_config
7551            .iter()
7552            .find(|view| view.output == output)
7553        else {
7554            // The parent graph exists but this output was not declared — a
7555            // clear error beats the misleading "collection not found".
7556            return Err(RedDBError::Query(format!(
7557                "analytics output '{output_name}' is not enabled on graph '{graph_name}'; declare it with WITH ANALYTICS (...)"
7558            )));
7559        };
7560
7561        // Policy inheritance (AC5): route through the parent graph collection's
7562        // read authorization. A policy or RLS rule that denies the parent
7563        // denies its analytics views transitively.
7564        let parent_query = TableQuery::new(graph_name);
7565        if self
7566            .authorize_relational_table_select(parent_query, frame)?
7567            .is_none()
7568        {
7569            return Err(RedDBError::Query(format!(
7570                "permission denied: policy on graph '{graph_name}' denies analytics view '{output_name}'"
7571            )));
7572        }
7573
7574        let (algorithm, named_args) = analytics_view_algorithm(graph_name, view)?;
7575        let (nodes, edges) = self.materialize_whole_graph_abstract()?;
7576        let result = self.dispatch_graph_algorithm(&algorithm, nodes, edges, &named_args)?;
7577        Ok(Some(result))
7578    }
7579
7580    /// Shared algorithm dispatch over abstract `(nodes, edges)` inputs.
7581    ///
7582    /// Both the graph-collection form and the inline-graph form route here so
7583    /// named-argument validation and the projected row shape stay identical
7584    /// across the two signatures (issue #799). Projects each algorithm's
7585    /// native output shape.
7586    fn dispatch_graph_algorithm(
7587        &self,
7588        name: &str,
7589        nodes: Vec<String>,
7590        edges: Vec<(
7591            String,
7592            String,
7593            crate::storage::engine::graph_algorithms::Weight,
7594        )>,
7595        named_args: &[(String, f64)],
7596    ) -> RedDBResult<crate::storage::query::unified::UnifiedResult> {
7597        use crate::storage::engine::graph_algorithms;
7598        use crate::storage::query::unified::UnifiedResult;
7599        use crate::storage::schema::Value;
7600
7601        if name.eq_ignore_ascii_case("components") {
7602            reject_named_args(name, named_args)?;
7603            let assignment = graph_algorithms::connected_components(&nodes, &edges);
7604            let mut result =
7605                UnifiedResult::with_columns(vec!["node_id".into(), "island_id".into()]);
7606            for (node_id, island_id) in assignment {
7607                let mut record = UnifiedRecord::new();
7608                record.set("node_id", Value::text(node_id));
7609                record.set("island_id", Value::Integer(island_id as i64));
7610                result.push(record);
7611            }
7612            return Ok(result);
7613        }
7614
7615        if name.eq_ignore_ascii_case("louvain") {
7616            // The only supported named argument is `resolution` (γ). It
7617            // defaults to 1.0 (classic modularity) and must be a finite,
7618            // strictly positive number — a non-positive (or NaN/inf)
7619            // resolution has no sensible meaning.
7620            let resolution = louvain_resolution(named_args)?;
7621            let assignment = graph_algorithms::louvain(&nodes, &edges, resolution);
7622            let mut result =
7623                UnifiedResult::with_columns(vec!["node_id".into(), "community_id".into()]);
7624            for (node_id, community_id) in assignment {
7625                let mut record = UnifiedRecord::new();
7626                record.set("node_id", Value::text(node_id));
7627                record.set("community_id", Value::Integer(community_id as i64));
7628                result.push(record);
7629            }
7630            return Ok(result);
7631        }
7632
7633        if name.eq_ignore_ascii_case("degree_centrality") {
7634            reject_named_args(name, named_args)?;
7635            let assignment = abstract_degree_centrality(&nodes, &edges);
7636            let mut result = UnifiedResult::with_columns(vec!["node_id".into(), "degree".into()]);
7637            for (node_id, degree) in assignment {
7638                let mut record = UnifiedRecord::new();
7639                record.set("node_id", Value::text(node_id));
7640                record.set("degree", Value::Integer(degree as i64));
7641                result.push(record);
7642            }
7643            return Ok(result);
7644        }
7645
7646        if name.eq_ignore_ascii_case("shortest_path") {
7647            // Scalar named arguments: `src` and `dst` are required node ids,
7648            // `max_hops` is an optional non-negative edge-count cap. Node ids
7649            // in the graph store are integer entity ids rendered as strings, so
7650            // each id arg must be a non-negative whole number; reject anything
7651            // else (fractional, negative, NaN/inf) with a clear message.
7652            let mut src: Option<String> = None;
7653            let mut dst: Option<String> = None;
7654            let mut max_hops: Option<usize> = None;
7655            let as_node_id = |key: &str, value: f64| -> RedDBResult<String> {
7656                if !value.is_finite() || value < 0.0 || value.fract() != 0.0 {
7657                    return Err(RedDBError::Query(format!(
7658                        "table function 'shortest_path' argument '{key}' must be a non-negative integer node id, got {value}"
7659                    )));
7660                }
7661                Ok((value as i64).to_string())
7662            };
7663            for (key, value) in named_args {
7664                if key.eq_ignore_ascii_case("src") {
7665                    src = Some(as_node_id("src", *value)?);
7666                } else if key.eq_ignore_ascii_case("dst") {
7667                    dst = Some(as_node_id("dst", *value)?);
7668                } else if key.eq_ignore_ascii_case("max_hops") {
7669                    if !value.is_finite() || *value < 0.0 || value.fract() != 0.0 {
7670                        return Err(RedDBError::Query(format!(
7671                            "table function 'shortest_path' max_hops must be a non-negative integer, got {value}"
7672                        )));
7673                    }
7674                    max_hops = Some(*value as usize);
7675                } else {
7676                    return Err(RedDBError::Query(format!(
7677                        "table function 'shortest_path' has no named argument '{key}' (expected 'src', 'dst', 'max_hops')"
7678                    )));
7679                }
7680            }
7681            let src = src.ok_or_else(|| {
7682                RedDBError::Query(
7683                    "table function 'shortest_path' requires named argument 'src'".to_string(),
7684                )
7685            })?;
7686            let dst = dst.ok_or_else(|| {
7687                RedDBError::Query(
7688                    "table function 'shortest_path' requires named argument 'dst'".to_string(),
7689                )
7690            })?;
7691
7692            // Columns are always present; an unreachable pair (within the
7693            // optional `max_hops` budget) simply yields zero rows — never an
7694            // error. `hop` is the 0-based index from the source;
7695            // `cumulative_weight` is the running path weight (0 at the source,
7696            // the total at the destination). Edges are treated as undirected,
7697            // consistent with `components` / `louvain`.
7698            let mut result = UnifiedResult::with_columns(vec![
7699                "hop".into(),
7700                "node_id".into(),
7701                "cumulative_weight".into(),
7702            ]);
7703            if let Some(path) =
7704                graph_algorithms::shortest_path(&nodes, &edges, &src, &dst, max_hops)
7705            {
7706                for (hop, (node_id, cumulative_weight)) in path.into_iter().enumerate() {
7707                    let mut record = UnifiedRecord::new();
7708                    record.set("hop", Value::Integer(hop as i64));
7709                    record.set("node_id", Value::text(node_id));
7710                    record.set("cumulative_weight", Value::Float(cumulative_weight));
7711                    result.push(record);
7712                }
7713            }
7714            return Ok(result);
7715        }
7716        // ── Centrality family (issue #797): each returns rows `(node_id,
7717        // score)` over the abstract `(nodes, edges)` graph. Like the other
7718        // graph TVFs the graph is treated as undirected and scores are
7719        // deterministic; the inline-graph form shares this dispatch. ──
7720        if name.eq_ignore_ascii_case("betweenness") {
7721            reject_named_args(name, named_args)?;
7722            return Ok(Self::centrality_result(graph_algorithms::betweenness(
7723                &nodes, &edges,
7724            )));
7725        }
7726        if name.eq_ignore_ascii_case("eigenvector") {
7727            // Optional `max_iterations` (positive integer, default 100) and
7728            // `tolerance` (finite, strictly positive, default 1e-6).
7729            let mut max_iterations = 100_usize;
7730            let mut tolerance = 1e-6_f64;
7731            for (key, value) in named_args {
7732                if key.eq_ignore_ascii_case("max_iterations") {
7733                    max_iterations = parse_positive_iterations("eigenvector", value)?;
7734                } else if key.eq_ignore_ascii_case("tolerance") {
7735                    if !value.is_finite() || *value <= 0.0 {
7736                        return Err(RedDBError::Query(format!(
7737                            "table function 'eigenvector' tolerance must be > 0, got {value}"
7738                        )));
7739                    }
7740                    tolerance = *value;
7741                } else {
7742                    return Err(RedDBError::Query(format!(
7743                        "table function 'eigenvector' has no named argument '{key}' (expected 'max_iterations' or 'tolerance')"
7744                    )));
7745                }
7746            }
7747            return Ok(Self::centrality_result(graph_algorithms::eigenvector(
7748                &nodes,
7749                &edges,
7750                max_iterations,
7751                tolerance,
7752            )));
7753        }
7754        if name.eq_ignore_ascii_case("pagerank") {
7755            // Optional `damping` (in (0, 1), default 0.85) and `max_iterations`
7756            // (positive integer, default 100).
7757            let mut damping = 0.85_f64;
7758            let mut max_iterations = 100_usize;
7759            for (key, value) in named_args {
7760                if key.eq_ignore_ascii_case("damping") {
7761                    if !value.is_finite() || *value <= 0.0 || *value >= 1.0 {
7762                        return Err(RedDBError::Query(format!(
7763                            "table function 'pagerank' damping must be in (0, 1), got {value}"
7764                        )));
7765                    }
7766                    damping = *value;
7767                } else if key.eq_ignore_ascii_case("max_iterations") {
7768                    max_iterations = parse_positive_iterations("pagerank", value)?;
7769                } else {
7770                    return Err(RedDBError::Query(format!(
7771                        "table function 'pagerank' has no named argument '{key}' (expected 'damping' or 'max_iterations')"
7772                    )));
7773                }
7774            }
7775            return Ok(Self::centrality_result(graph_algorithms::pagerank(
7776                &nodes,
7777                &edges,
7778                damping,
7779                max_iterations,
7780            )));
7781        }
7782        Err(RedDBError::Query(format!("unknown table function: {name}")))
7783    }
7784
7785    /// `components(<graph_collection>)` — returns rows `(node_id, island_id)`.
7786    ///
7787    /// Materializes the active graph (nodes + weighted edges) read-only and
7788    /// runs the pure `graph_algorithms::connected_components`. Edges are
7789    /// treated as undirected; island ids are deterministic (ascending order of
7790    /// each component's smallest node).
7791    fn execute_components_tvf(
7792        &self,
7793        _collection: &str,
7794    ) -> RedDBResult<crate::storage::query::unified::UnifiedResult> {
7795        use crate::storage::engine::graph_algorithms;
7796        use crate::storage::query::unified::UnifiedResult;
7797        use crate::storage::schema::Value;
7798
7799        // Read-only materialization of the full active graph. The named
7800        // collection identifies the active graph scope; passing `None` for the
7801        // projection uses the full graph store (the same result
7802        // `active_graph_projection` yields when no projection is registered).
7803        // Materialization never mutates any store.
7804        let graph = super::graph_dsl::materialize_graph_with_projection(
7805            self.inner.db.store().as_ref(),
7806            None,
7807        )?;
7808
7809        // Materialize abstract inputs for the pure algorithm.
7810        let nodes: Vec<String> = graph.iter_nodes().map(|n| n.id.clone()).collect();
7811        let edges: Vec<(String, String, graph_algorithms::Weight)> = graph
7812            .iter_all_edges()
7813            .into_iter()
7814            .map(|e| (e.source_id, e.target_id, e.weight))
7815            .collect();
7816
7817        let assignment = graph_algorithms::connected_components(&nodes, &edges);
7818
7819        // Project into a UnifiedResult with columns ["node_id", "island_id"].
7820        let mut result = UnifiedResult::with_columns(vec!["node_id".into(), "island_id".into()]);
7821        for (node_id, island_id) in assignment {
7822            let mut record = UnifiedRecord::new();
7823            record.set("node_id", Value::text(node_id));
7824            record.set("island_id", Value::Integer(island_id as i64));
7825            result.push(record);
7826        }
7827        Ok(result)
7828    }
7829
7830    /// `louvain(<graph> [, resolution => <f64>])` — returns rows
7831    /// `(node_id, community_id)` (issue #796).
7832    ///
7833    /// Materializes the active graph (nodes + weighted edges) read-only and
7834    /// runs the pure, deterministic `graph_algorithms::louvain`. Edges are
7835    /// treated as undirected; community ids are assigned in ascending order of
7836    /// each community's smallest node, so identical input + resolution always
7837    /// yields identical rows. Like `components`, the v0 form runs over the
7838    /// whole graph store regardless of the collection argument value.
7839    fn execute_louvain_tvf(
7840        &self,
7841        _collection: &str,
7842        resolution: f64,
7843    ) -> RedDBResult<crate::storage::query::unified::UnifiedResult> {
7844        use crate::storage::engine::graph_algorithms;
7845        use crate::storage::query::unified::UnifiedResult;
7846        use crate::storage::schema::Value;
7847
7848        let graph = super::graph_dsl::materialize_graph_with_projection(
7849            self.inner.db.store().as_ref(),
7850            None,
7851        )?;
7852
7853        let nodes: Vec<String> = graph.iter_nodes().map(|n| n.id.clone()).collect();
7854        let edges: Vec<(String, String, graph_algorithms::Weight)> = graph
7855            .iter_all_edges()
7856            .into_iter()
7857            .map(|e| (e.source_id, e.target_id, e.weight))
7858            .collect();
7859
7860        let assignment = graph_algorithms::louvain(&nodes, &edges, resolution);
7861
7862        // Project into a UnifiedResult with columns ["node_id", "community_id"].
7863        let mut result = UnifiedResult::with_columns(vec!["node_id".into(), "community_id".into()]);
7864        for (node_id, community_id) in assignment {
7865            let mut record = UnifiedRecord::new();
7866            record.set("node_id", Value::text(node_id));
7867            record.set("community_id", Value::Integer(community_id as i64));
7868            result.push(record);
7869        }
7870        Ok(result)
7871    }
7872
7873    /// Project `(node_id, score)` centrality rows into a `UnifiedResult` with
7874    /// columns `["node_id", "score"]`; scores are `Value::Float`.
7875    fn centrality_result(
7876        rows: Vec<(String, f64)>,
7877    ) -> crate::storage::query::unified::UnifiedResult {
7878        use crate::storage::query::unified::UnifiedResult;
7879        use crate::storage::schema::Value;
7880        let mut result = UnifiedResult::with_columns(vec!["node_id".into(), "score".into()]);
7881        for (node_id, score) in rows {
7882            let mut record = UnifiedRecord::new();
7883            record.set("node_id", Value::text(node_id));
7884            record.set("score", Value::Float(score));
7885            result.push(record);
7886        }
7887        result
7888    }
7889
7890    /// Ultra-fast path: detect `SELECT * FROM table WHERE _entity_id = N` by string pattern
7891    /// and execute it without SQL parsing or planning. Returns None if pattern doesn't match.
7892    fn try_fast_entity_lookup(&self, query: &str) -> Option<RedDBResult<RuntimeQueryResult>> {
7893        // Pattern: "SELECT * FROM <table> WHERE _entity_id = <id>"
7894        // or "SELECT * FROM <table> WHERE _entity_id =<id>"
7895        let q = query.trim();
7896        if !q.starts_with("SELECT") && !q.starts_with("select") {
7897            return None;
7898        }
7899
7900        // Find "WHERE _entity_id = " or "WHERE _entity_id ="
7901        let where_pos = q
7902            .find("WHERE _entity_id")
7903            .or_else(|| q.find("where _entity_id"))?;
7904        let after_field = &q[where_pos + 16..].trim_start(); // skip "WHERE _entity_id"
7905        let after_eq = after_field.strip_prefix('=')?.trim_start();
7906
7907        // Parse the entity ID number
7908        let id_str = after_eq.trim();
7909        let entity_id: u64 = id_str.parse().ok()?;
7910
7911        // Extract table name: between "FROM " and " WHERE"
7912        let from_pos = q.find("FROM ").or_else(|| q.find("from "))? + 5;
7913        let table = q[from_pos..where_pos].trim();
7914        if table.is_empty()
7915            || table.contains(' ') && !table.contains(" AS ") && !table.contains(" as ")
7916        {
7917            return None; // complex query, fall through
7918        }
7919        let table_name = table.split_whitespace().next()?;
7920
7921        // Direct entity lookup — skips SQL parse, plan cache, result
7922        // cache, view rewriter, RLS gate. Safe because the gating in
7923        // `execute_query` guarantees no scope override / no
7924        // transaction context is active. MVCC visibility is still
7925        // honoured against the current snapshot.
7926        let store = self.inner.db.store();
7927        let entity = store
7928            .get(
7929                table_name,
7930                crate::storage::unified::EntityId::new(entity_id),
7931            )
7932            .filter(entity_visible_under_current_snapshot)
7933            .filter(|entity| {
7934                self.inner
7935                    .db
7936                    .replica_allows_entity_at_read(table_name, entity)
7937            });
7938
7939        let count = if entity.is_some() { 1u64 } else { 0 };
7940
7941        // Materialize a record so downstream consumers that walk
7942        // `result.records` (embedded runtime API, decrypt pass, CLI)
7943        // see the row. Previously only `pre_serialized_json` was
7944        // filled, which caused those consumers to see zero rows and
7945        // skewed benchmarks.
7946        let records: Vec<crate::storage::query::unified::UnifiedRecord> = entity
7947            .as_ref()
7948            .and_then(|e| runtime_table_record_from_entity(e.clone()))
7949            .into_iter()
7950            .collect();
7951
7952        let json = match entity {
7953            Some(ref e) => execute_runtime_serialize_single_entity(e),
7954            None => r#"{"columns":[],"record_count":0,"selection":{"scope":"any"},"records":[]}"#
7955                .to_string(),
7956        };
7957
7958        Some(Ok(RuntimeQueryResult {
7959            query: query.to_string(),
7960            mode: crate::storage::query::modes::QueryMode::Sql,
7961            statement: "select",
7962            engine: "fast-entity-lookup",
7963            result: crate::storage::query::unified::UnifiedResult {
7964                columns: Vec::new(),
7965                records,
7966                stats: crate::storage::query::unified::QueryStats {
7967                    rows_scanned: count,
7968                    ..Default::default()
7969                },
7970                pre_serialized_json: Some(json),
7971            },
7972            affected_rows: 0,
7973            statement_type: "select",
7974            bookmark: None,
7975        }))
7976    }
7977
7978    pub(crate) fn invalidate_plan_cache(&self) {
7979        self.inner.query_cache.write().clear();
7980        self.inner
7981            .ddl_epoch
7982            .fetch_add(1, std::sync::atomic::Ordering::Release);
7983    }
7984
7985    /// Read the monotonic DDL epoch counter. Bumped by every
7986    /// `invalidate_plan_cache` call so prepared-statement holders can
7987    /// detect schema drift between PREPARE and EXECUTE.
7988    pub fn ddl_epoch(&self) -> u64 {
7989        self.inner
7990            .ddl_epoch
7991            .load(std::sync::atomic::Ordering::Acquire)
7992    }
7993
7994    pub(crate) fn clear_table_planner_stats(&self, table: &str) {
7995        let store = self.inner.db.store();
7996        crate::storage::query::planner::stats_catalog::clear_table_stats(store.as_ref(), table);
7997        self.invalidate_plan_cache();
7998    }
7999
8000    /// Replay `tenant_tables.*.column` keys from red_config at boot so
8001    /// `CREATE TABLE ... TENANT BY (col)` declarations persist across
8002    /// restarts (Phase 2.5.4). Reads every row of the `red_config`
8003    /// collection, picks the keys matching the tenant-marker shape,
8004    /// and calls `register_tenant_table` for each.
8005    ///
8006    /// Safe no-op when `red_config` doesn't exist (first boot on a
8007    /// fresh datadir).
8008    pub(crate) fn rehydrate_tenant_tables(&self) {
8009        let store = self.inner.db.store();
8010        let Some(manager) = store.get_collection("red_config") else {
8011            return;
8012        };
8013        // Replay in insertion order (SegmentManager iteration). Multiple
8014        // toggles on the same table leave several rows behind — the
8015        // last one processed wins because each register/unregister
8016        // call overwrites the in-memory state.
8017        for entity in manager.query_all(|_| true) {
8018            let crate::storage::unified::entity::EntityData::Row(row) = &entity.data else {
8019                continue;
8020            };
8021            let Some(named) = &row.named else { continue };
8022            let Some(crate::storage::schema::Value::Text(key)) = named.get("key") else {
8023                continue;
8024            };
8025            // Shape: tenant_tables.{table}.column
8026            let Some(rest) = key.strip_prefix("tenant_tables.") else {
8027                continue;
8028            };
8029            let Some((table, suffix)) = rest.rsplit_once('.') else {
8030                // Issue #205 — a `tenant_tables.*` row that doesn't
8031                // split cleanly is a schema-shape regression: the
8032                // metadata writer must always emit the `.column`
8033                // suffix, so reaching this branch means an upgrade
8034                // with incompatible state or external tampering.
8035                crate::telemetry::operator_event::OperatorEvent::SchemaCorruption {
8036                    collection: "red_config".to_string(),
8037                    detail: format!("malformed tenant_tables key: {key}"),
8038                }
8039                .emit_global();
8040                continue;
8041            };
8042            if suffix != "column" {
8043                crate::telemetry::operator_event::OperatorEvent::SchemaCorruption {
8044                    collection: "red_config".to_string(),
8045                    detail: format!("unexpected tenant_tables suffix: {key}"),
8046                }
8047                .emit_global();
8048                continue;
8049            }
8050            match named.get("value") {
8051                Some(crate::storage::schema::Value::Text(column)) => {
8052                    self.register_tenant_table(table, column);
8053                }
8054                // Null / missing value = DISABLE TENANCY marker.
8055                Some(crate::storage::schema::Value::Null) | None => {
8056                    self.unregister_tenant_table(table);
8057                }
8058                _ => {}
8059            }
8060        }
8061    }
8062
8063    /// Replay every persisted `MaterializedViewDescriptor` from the
8064    /// `red_materialized_view_defs` system collection (issue #593
8065    /// slice 9a). For each descriptor, re-parse the original SQL,
8066    /// extract the `QueryExpr::CreateView` it produced, and populate
8067    /// the in-memory registries (`inner.views` and
8068    /// `inner.materialized_views`) directly — no write paths run, so
8069    /// rehydrate does not re-persist what it just read.
8070    ///
8071    /// Malformed rows (missing `name`/`source_sql`, parse errors) are
8072    /// skipped with a `SchemaCorruption` operator event so a single
8073    /// bad entry does not block startup.
8074    pub(crate) fn rehydrate_materialized_view_descriptors(&self) {
8075        let store = self.inner.db.store();
8076        let descriptors = crate::runtime::continuous_materialized_view::load_all(store.as_ref());
8077        for descriptor in descriptors {
8078            let parsed = match crate::storage::query::parser::parse(&descriptor.source_sql) {
8079                Ok(qc) => qc,
8080                Err(err) => {
8081                    crate::telemetry::operator_event::OperatorEvent::SchemaCorruption {
8082                        collection:
8083                            crate::runtime::continuous_materialized_view::CATALOG_COLLECTION
8084                                .to_string(),
8085                        detail: format!(
8086                            "failed to re-parse materialized-view source for {}: {err}",
8087                            descriptor.name
8088                        ),
8089                    }
8090                    .emit_global();
8091                    continue;
8092                }
8093            };
8094            let crate::storage::query::ast::QueryExpr::CreateView(create) = parsed.query else {
8095                crate::telemetry::operator_event::OperatorEvent::SchemaCorruption {
8096                    collection: crate::runtime::continuous_materialized_view::CATALOG_COLLECTION
8097                        .to_string(),
8098                    detail: format!(
8099                        "materialized-view source for {} did not re-parse as CREATE VIEW",
8100                        descriptor.name
8101                    ),
8102                }
8103                .emit_global();
8104                continue;
8105            };
8106            // Populate in-memory view registry.
8107            let view_name = create.name.clone();
8108            self.inner
8109                .views
8110                .write()
8111                .insert(view_name.clone(), Arc::new(create));
8112            // Materialized cache slot (data empty until next REFRESH).
8113            use crate::storage::cache::result::{MaterializedViewDef, RefreshPolicy};
8114            let refresh = match descriptor.refresh_every_ms {
8115                Some(ms) => RefreshPolicy::Periodic(std::time::Duration::from_millis(ms)),
8116                None => RefreshPolicy::Manual,
8117            };
8118            let def = MaterializedViewDef {
8119                name: view_name.clone(),
8120                query: format!("<parsed view {}>", view_name),
8121                dependencies: descriptor.source_collections.clone(),
8122                refresh,
8123                retention_duration_ms: descriptor.retention_duration_ms,
8124            };
8125            self.inner.materialized_views.write().register(def);
8126            if let Err(err) = self.ensure_materialized_view_backing(&view_name) {
8127                crate::telemetry::operator_event::OperatorEvent::SchemaCorruption {
8128                    collection: crate::runtime::continuous_materialized_view::CATALOG_COLLECTION
8129                        .to_string(),
8130                    detail: format!(
8131                        "failed to rehydrate backing collection for materialized view {view_name}: {err}"
8132                    ),
8133                }
8134                .emit_global();
8135            }
8136        }
8137        // A rehydrated view shape may differ from any plans the cache
8138        // bootstrapped before this method ran — flush to be safe.
8139        self.invalidate_plan_cache();
8140    }
8141
8142    pub(crate) fn rehydrate_declared_column_schemas(&self) {
8143        let store = self.inner.db.store();
8144        for contract in self.inner.db.collection_contracts() {
8145            let columns: Vec<String> = contract
8146                .declared_columns
8147                .iter()
8148                .map(|column| column.name.clone())
8149                .collect();
8150            let Some(manager) = store.get_collection(&contract.name) else {
8151                continue;
8152            };
8153            manager.set_column_schema_if_empty(columns);
8154        }
8155    }
8156
8157    /// Register a table as tenant-scoped (Phase 2.5.4). Installs the
8158    /// in-memory column mapping, the implicit RLS policy, and enables
8159    /// row-level security on the table. Idempotent — re-registering
8160    /// the same `(table, column)` replaces the prior auto-policy.
8161    pub fn register_tenant_table(&self, table: &str, column: &str) {
8162        use crate::storage::query::ast::{
8163            CompareOp, CreatePolicyQuery, Expr, FieldRef, Filter, Span,
8164        };
8165        self.inner
8166            .tenant_tables
8167            .write()
8168            .insert(table.to_string(), column.to_string());
8169
8170        // Build the policy: col = CURRENT_TENANT()
8171        // Uses CompareExpr so the comparison happens at runtime against
8172        // the thread-local tenant value read by the CURRENT_TENANT
8173        // scalar. Spans are synthetic — there's no source location for
8174        // an auto-generated policy.
8175        let lhs = Expr::Column {
8176            field: FieldRef::TableColumn {
8177                table: table.to_string(),
8178                column: column.to_string(),
8179            },
8180            span: Span::synthetic(),
8181        };
8182        let rhs = Expr::FunctionCall {
8183            name: "CURRENT_TENANT".to_string(),
8184            args: Vec::new(),
8185            span: Span::synthetic(),
8186        };
8187        let policy_filter = Filter::CompareExpr {
8188            lhs,
8189            op: CompareOp::Eq,
8190            rhs,
8191        };
8192
8193        let policy = CreatePolicyQuery {
8194            name: "__tenant_iso".to_string(),
8195            table: table.to_string(),
8196            action: None, // None = ALL actions (SELECT/INSERT/UPDATE/DELETE)
8197            role: None,   // None = every role
8198            using: Box::new(policy_filter),
8199            // Auto-tenancy defaults to Table targets. Collections of
8200            // other kinds (graph / vector / queue / timeseries) that
8201            // opt in via `ALTER ... ENABLE TENANCY` should use the
8202            // matching kind — but for now we keep the auto-policy
8203            // kind-agnostic so the evaluator can apply it to any
8204            // entity living in the collection.
8205            target_kind: crate::storage::query::ast::PolicyTargetKind::Table,
8206        };
8207
8208        // Replace any prior auto-policy for this table (column rename).
8209        self.inner.rls_policies.write().insert(
8210            (table.to_string(), "__tenant_iso".to_string()),
8211            Arc::new(policy),
8212        );
8213        self.inner
8214            .rls_enabled_tables
8215            .write()
8216            .insert(table.to_string());
8217
8218        // Auto-build a hash index on the tenant column. Every read/write
8219        // against a tenant-scoped table carries an implicit
8220        // `col = CURRENT_TENANT()` predicate from the auto-policy, so an
8221        // index on that column is on the hot path of every query. Without
8222        // it, every SELECT/UPDATE/DELETE degrades to a full scan.
8223        self.ensure_tenant_index(table, column);
8224    }
8225
8226    /// Auto-create the hash index that backs the tenant-iso RLS predicate.
8227    /// Skipped when:
8228    ///   * the column is dotted (nested path — flat secondary indices
8229    ///     don't cover those today; RLS still works via the policy)
8230    ///   * `__tenant_idx_{table}` already exists (idempotent on rehydrate)
8231    ///   * the user already registered an index whose first column matches
8232    ///     (avoids redundant duplicates of a user-defined composite)
8233    fn ensure_tenant_index(&self, table: &str, column: &str) {
8234        if column.contains('.') {
8235            return;
8236        }
8237        let index_name = format!("__tenant_idx_{table}");
8238        let registry = self.inner.index_store.list_indices(table);
8239        if registry.iter().any(|idx| idx.name == index_name) {
8240            return;
8241        }
8242        if registry
8243            .iter()
8244            .any(|idx| idx.columns.first().map(|c| c.as_str()) == Some(column))
8245        {
8246            return;
8247        }
8248
8249        let store = self.inner.db.store();
8250        let Some(manager) = store.get_collection(table) else {
8251            return;
8252        };
8253        let entities = manager.query_all(|_| true);
8254        let entity_fields: Vec<(
8255            crate::storage::unified::EntityId,
8256            Vec<(String, crate::storage::schema::Value)>,
8257        )> = entities
8258            .iter()
8259            .map(|e| {
8260                let fields = match &e.data {
8261                    crate::storage::EntityData::Row(row) => {
8262                        if let Some(ref named) = row.named {
8263                            named.iter().map(|(k, v)| (k.clone(), v.clone())).collect()
8264                        } else if let Some(ref schema) = row.schema {
8265                            schema
8266                                .iter()
8267                                .zip(row.columns.iter())
8268                                .map(|(k, v)| (k.clone(), v.clone()))
8269                                .collect()
8270                        } else {
8271                            Vec::new()
8272                        }
8273                    }
8274                    crate::storage::EntityData::Node(node) => node
8275                        .properties
8276                        .iter()
8277                        .map(|(k, v)| (k.clone(), v.clone()))
8278                        .collect(),
8279                    _ => Vec::new(),
8280                };
8281                (e.id, fields)
8282            })
8283            .collect();
8284
8285        let columns = vec![column.to_string()];
8286        if self
8287            .inner
8288            .index_store
8289            .create_index(
8290                &index_name,
8291                table,
8292                &columns,
8293                super::index_store::IndexMethodKind::Hash,
8294                false,
8295                &entity_fields,
8296            )
8297            .is_err()
8298        {
8299            return;
8300        }
8301        self.inner
8302            .index_store
8303            .register(super::index_store::RegisteredIndex {
8304                name: index_name,
8305                collection: table.to_string(),
8306                columns,
8307                method: super::index_store::IndexMethodKind::Hash,
8308                unique: false,
8309            });
8310        self.invalidate_plan_cache();
8311    }
8312
8313    /// Drop the auto-generated tenant index, if one exists. Called from
8314    /// `unregister_tenant_table` so DISABLE TENANCY / DROP TABLE clean up.
8315    fn drop_tenant_index(&self, table: &str) {
8316        let index_name = format!("__tenant_idx_{table}");
8317        self.inner.index_store.drop_index(&index_name, table);
8318    }
8319
8320    /// Retrieve the tenant column for a table, if any (Phase 2.5.4).
8321    /// Used by the INSERT auto-fill path to know which column to
8322    /// populate with `current_tenant()` when the user didn't name it.
8323    pub fn tenant_column(&self, table: &str) -> Option<String> {
8324        self.inner.tenant_tables.read().get(table).cloned()
8325    }
8326
8327    /// Remove a table's tenant registration (Phase 2.5.4). Called by
8328    /// DROP TABLE / ALTER TABLE DISABLE TENANCY. Removes the auto-policy
8329    /// but leaves any user-installed explicit policies intact.
8330    pub fn unregister_tenant_table(&self, table: &str) {
8331        self.inner.tenant_tables.write().remove(table);
8332        self.inner
8333            .rls_policies
8334            .write()
8335            .remove(&(table.to_string(), "__tenant_iso".to_string()));
8336        self.drop_tenant_index(table);
8337        // Only clear RLS enablement if no other policies remain.
8338        let has_other_policies = self
8339            .inner
8340            .rls_policies
8341            .read()
8342            .keys()
8343            .any(|(t, _)| t == table);
8344        if !has_other_policies {
8345            self.inner.rls_enabled_tables.write().remove(table);
8346        }
8347    }
8348
8349    /// Record that the running transaction has marked `id` in `collection`
8350    /// for deletion (Phase 2.3.2b MVCC tombstones). `stamper_xid` is the
8351    /// xid that was written into `xmax` — either the parent txn xid or
8352    /// the innermost savepoint sub-xid. Savepoint rollback filters by
8353    /// this xid to revive only its own tombstones.
8354    pub(crate) fn record_pending_tombstone(
8355        &self,
8356        conn_id: u64,
8357        collection: &str,
8358        id: crate::storage::unified::entity::EntityId,
8359        stamper_xid: crate::storage::transaction::snapshot::Xid,
8360        previous_xmax: crate::storage::transaction::snapshot::Xid,
8361    ) {
8362        self.inner
8363            .pending_tombstones
8364            .write()
8365            .entry(conn_id)
8366            .or_default()
8367            .push((collection.to_string(), id, stamper_xid, previous_xmax));
8368    }
8369
8370    pub(crate) fn record_pending_versioned_update(
8371        &self,
8372        conn_id: u64,
8373        collection: &str,
8374        old_id: crate::storage::unified::entity::EntityId,
8375        new_id: crate::storage::unified::entity::EntityId,
8376        stamper_xid: crate::storage::transaction::snapshot::Xid,
8377        previous_xmax: crate::storage::transaction::snapshot::Xid,
8378    ) {
8379        self.inner
8380            .pending_versioned_updates
8381            .write()
8382            .entry(conn_id)
8383            .or_default()
8384            .push((
8385                collection.to_string(),
8386                old_id,
8387                new_id,
8388                stamper_xid,
8389                previous_xmax,
8390            ));
8391    }
8392
8393    fn with_deferred_store_wal_if_transaction<T>(
8394        &self,
8395        f: impl FnOnce() -> RedDBResult<T>,
8396    ) -> RedDBResult<T> {
8397        let conn_id = current_connection_id();
8398        if !self.inner.tx_contexts.read().contains_key(&conn_id) {
8399            return f();
8400        }
8401
8402        crate::storage::UnifiedStore::begin_deferred_store_wal_capture();
8403        let result = f();
8404        let captured = crate::storage::UnifiedStore::take_deferred_store_wal_capture();
8405        match result {
8406            Ok(value) => {
8407                self.record_pending_store_wal_actions(conn_id, captured);
8408                Ok(value)
8409            }
8410            Err(err) => Err(err),
8411        }
8412    }
8413
8414    fn with_deferred_store_wal_for_dml<T>(
8415        &self,
8416        capture_autocommit_events: bool,
8417        f: impl FnOnce() -> RedDBResult<T>,
8418    ) -> RedDBResult<T> {
8419        let conn_id = current_connection_id();
8420        if self.inner.tx_contexts.read().contains_key(&conn_id) {
8421            return self.with_deferred_store_wal_if_transaction(f);
8422        }
8423        if !capture_autocommit_events {
8424            return f();
8425        }
8426
8427        crate::storage::UnifiedStore::begin_deferred_store_wal_capture();
8428        let result = f();
8429        let captured = crate::storage::UnifiedStore::take_deferred_store_wal_capture();
8430        self.inner
8431            .db
8432            .store()
8433            .append_deferred_store_wal_actions(captured)
8434            .map_err(|err| RedDBError::Internal(err.to_string()))?;
8435        result
8436    }
8437
8438    fn insert_may_emit_events(&self, query: &InsertQuery) -> bool {
8439        !query.suppress_events
8440            && self.collection_has_event_subscriptions_for_operation(
8441                &query.table,
8442                crate::catalog::SubscriptionOperation::Insert,
8443            )
8444    }
8445
8446    fn update_may_emit_events(&self, query: &UpdateQuery) -> bool {
8447        !query.suppress_events
8448            && self.collection_has_event_subscriptions_for_operation(
8449                &query.table,
8450                crate::catalog::SubscriptionOperation::Update,
8451            )
8452    }
8453
8454    fn delete_may_emit_events(&self, query: &DeleteQuery) -> bool {
8455        !query.suppress_events
8456            && self.collection_has_event_subscriptions_for_operation(
8457                &query.table,
8458                crate::catalog::SubscriptionOperation::Delete,
8459            )
8460    }
8461
8462    fn collection_has_event_subscriptions_for_operation(
8463        &self,
8464        collection: &str,
8465        operation: crate::catalog::SubscriptionOperation,
8466    ) -> bool {
8467        let Some(contract) = self.db().collection_contract_arc(collection) else {
8468            return false;
8469        };
8470        contract.subscriptions.iter().any(|subscription| {
8471            subscription.enabled
8472                && (subscription.ops_filter.is_empty()
8473                    || subscription.ops_filter.contains(&operation))
8474        })
8475    }
8476
8477    fn record_pending_store_wal_actions(
8478        &self,
8479        conn_id: u64,
8480        actions: crate::storage::unified::DeferredStoreWalActions,
8481    ) {
8482        if actions.is_empty() {
8483            return;
8484        }
8485        let mut guard = self.inner.pending_store_wal_actions.write();
8486        guard.entry(conn_id).or_default().extend(actions);
8487    }
8488
8489    fn flush_pending_store_wal_actions(&self, conn_id: u64) -> RedDBResult<()> {
8490        let Some(actions) = self
8491            .inner
8492            .pending_store_wal_actions
8493            .write()
8494            .remove(&conn_id)
8495        else {
8496            return Ok(());
8497        };
8498        self.inner
8499            .db
8500            .store()
8501            .append_deferred_store_wal_actions(actions)
8502            .map_err(|err| RedDBError::Internal(err.to_string()))
8503    }
8504
8505    fn discard_pending_store_wal_actions(&self, conn_id: u64) {
8506        self.inner
8507            .pending_store_wal_actions
8508            .write()
8509            .remove(&conn_id);
8510    }
8511
8512    fn xid_conflicts_with_snapshot(
8513        &self,
8514        xid: crate::storage::transaction::snapshot::Xid,
8515        snapshot: &crate::storage::transaction::snapshot::Snapshot,
8516        own_xids: &std::collections::HashSet<crate::storage::transaction::snapshot::Xid>,
8517    ) -> bool {
8518        xid != 0
8519            && !own_xids.contains(&xid)
8520            && !self.inner.snapshot_manager.is_aborted(xid)
8521            && !self.inner.snapshot_manager.is_active(xid)
8522            && (xid > snapshot.xid || snapshot.in_progress.contains(&xid))
8523    }
8524
8525    fn conflict_error(
8526        collection: &str,
8527        logical_id: crate::storage::unified::entity::EntityId,
8528        xid: crate::storage::transaction::snapshot::Xid,
8529    ) -> RedDBError {
8530        RedDBError::Query(format!(
8531            "serialization conflict: table row {collection}/{} was modified by concurrent transaction {xid}",
8532            logical_id.raw()
8533        ))
8534    }
8535
8536    fn check_logical_row_conflict(
8537        &self,
8538        collection: &str,
8539        logical_id: crate::storage::unified::entity::EntityId,
8540        excluded_ids: &[crate::storage::unified::entity::EntityId],
8541        snapshot: &crate::storage::transaction::snapshot::Snapshot,
8542        own_xids: &std::collections::HashSet<crate::storage::transaction::snapshot::Xid>,
8543    ) -> RedDBResult<()> {
8544        let store = self.inner.db.store();
8545        let Some(manager) = store.get_collection(collection) else {
8546            return Ok(());
8547        };
8548
8549        for candidate in manager.query_all(|_| true) {
8550            if excluded_ids.contains(&candidate.id) || candidate.logical_id() != logical_id {
8551                continue;
8552            }
8553            if self.xid_conflicts_with_snapshot(candidate.xmin, snapshot, own_xids) {
8554                return Err(Self::conflict_error(collection, logical_id, candidate.xmin));
8555            }
8556            if self.xid_conflicts_with_snapshot(candidate.xmax, snapshot, own_xids) {
8557                return Err(Self::conflict_error(collection, logical_id, candidate.xmax));
8558            }
8559        }
8560        Ok(())
8561    }
8562
8563    pub(crate) fn check_table_row_write_conflicts(
8564        &self,
8565        conn_id: u64,
8566        snapshot: &crate::storage::transaction::snapshot::Snapshot,
8567        own_xids: &std::collections::HashSet<crate::storage::transaction::snapshot::Xid>,
8568    ) -> RedDBResult<()> {
8569        let versioned_updates = self
8570            .inner
8571            .pending_versioned_updates
8572            .read()
8573            .get(&conn_id)
8574            .cloned()
8575            .unwrap_or_default();
8576        let tombstones = self
8577            .inner
8578            .pending_tombstones
8579            .read()
8580            .get(&conn_id)
8581            .cloned()
8582            .unwrap_or_default();
8583
8584        let store = self.inner.db.store();
8585        for (collection, old_id, new_id, xid, previous_xmax) in versioned_updates {
8586            let Some(manager) = store.get_collection(&collection) else {
8587                continue;
8588            };
8589            let Some(old) = manager.get(old_id) else {
8590                continue;
8591            };
8592            let logical_id = old.logical_id();
8593            if self.xid_conflicts_with_snapshot(previous_xmax, snapshot, own_xids) {
8594                return Err(Self::conflict_error(&collection, logical_id, previous_xmax));
8595            }
8596            if old.xmax != xid && self.xid_conflicts_with_snapshot(old.xmax, snapshot, own_xids) {
8597                return Err(Self::conflict_error(&collection, logical_id, old.xmax));
8598            }
8599            self.check_logical_row_conflict(
8600                &collection,
8601                logical_id,
8602                &[old_id, new_id],
8603                snapshot,
8604                own_xids,
8605            )?;
8606        }
8607
8608        for (collection, id, xid, previous_xmax) in tombstones {
8609            let Some(manager) = store.get_collection(&collection) else {
8610                continue;
8611            };
8612            let Some(entity) = manager.get(id) else {
8613                continue;
8614            };
8615            let logical_id = entity.logical_id();
8616            if self.xid_conflicts_with_snapshot(previous_xmax, snapshot, own_xids) {
8617                return Err(Self::conflict_error(&collection, logical_id, previous_xmax));
8618            }
8619            if entity.xmax != xid
8620                && self.xid_conflicts_with_snapshot(entity.xmax, snapshot, own_xids)
8621            {
8622                return Err(Self::conflict_error(&collection, logical_id, entity.xmax));
8623            }
8624            self.check_logical_row_conflict(&collection, logical_id, &[id], snapshot, own_xids)?;
8625        }
8626
8627        Ok(())
8628    }
8629
8630    pub(crate) fn restore_pending_write_stamps(&self, conn_id: u64) {
8631        let versioned_updates = self
8632            .inner
8633            .pending_versioned_updates
8634            .read()
8635            .get(&conn_id)
8636            .cloned()
8637            .unwrap_or_default();
8638        let tombstones = self
8639            .inner
8640            .pending_tombstones
8641            .read()
8642            .get(&conn_id)
8643            .cloned()
8644            .unwrap_or_default();
8645
8646        let store = self.inner.db.store();
8647        for (collection, old_id, _new_id, xid, _previous_xmax) in versioned_updates {
8648            if let Some(manager) = store.get_collection(&collection) {
8649                if let Some(mut entity) = manager.get(old_id) {
8650                    entity.set_xmax(xid);
8651                    let _ = manager.update(entity);
8652                }
8653            }
8654        }
8655        for (collection, id, xid, _previous_xmax) in tombstones {
8656            if let Some(manager) = store.get_collection(&collection) {
8657                if let Some(mut entity) = manager.get(id) {
8658                    entity.set_xmax(xid);
8659                    let _ = manager.update(entity);
8660                }
8661            }
8662        }
8663    }
8664
8665    pub(crate) fn finalize_pending_versioned_updates(&self, conn_id: u64) {
8666        self.inner
8667            .pending_versioned_updates
8668            .write()
8669            .remove(&conn_id);
8670    }
8671
8672    pub(crate) fn revive_pending_versioned_updates(&self, conn_id: u64) {
8673        let Some(pending) = self
8674            .inner
8675            .pending_versioned_updates
8676            .write()
8677            .remove(&conn_id)
8678        else {
8679            return;
8680        };
8681
8682        let store = self.inner.db.store();
8683        for (collection, old_id, new_id, xid, previous_xmax) in pending {
8684            if let Some(manager) = store.get_collection(&collection) {
8685                if let Some(mut old) = manager.get(old_id) {
8686                    if old.xmax == xid {
8687                        old.set_xmax(previous_xmax);
8688                        let _ = manager.update(old);
8689                    }
8690                }
8691            }
8692            let _ = store.delete_batch(&collection, &[new_id]);
8693        }
8694    }
8695
8696    pub(crate) fn revive_versioned_updates_since(&self, conn_id: u64, stamper_xid: u64) -> usize {
8697        let mut guard = self.inner.pending_versioned_updates.write();
8698        let Some(pending) = guard.get_mut(&conn_id) else {
8699            return 0;
8700        };
8701
8702        let store = self.inner.db.store();
8703        let mut reverted = 0usize;
8704        pending.retain(|(collection, old_id, new_id, xid, previous_xmax)| {
8705            if *xid < stamper_xid {
8706                return true;
8707            }
8708            if let Some(manager) = store.get_collection(collection) {
8709                if let Some(mut old) = manager.get(*old_id) {
8710                    if old.xmax == *xid {
8711                        old.set_xmax(*previous_xmax);
8712                        let _ = manager.update(old);
8713                    }
8714                }
8715            }
8716            let _ = store.delete_batch(collection, &[*new_id]);
8717            reverted += 1;
8718            false
8719        });
8720        if pending.is_empty() {
8721            guard.remove(&conn_id);
8722        }
8723        reverted
8724    }
8725
8726    /// Flush tombstones on COMMIT. The xmax stamp is already the durable
8727    /// delete marker; commit only drops the rollback journal and emits
8728    /// side effects. Physical reclamation is left for VACUUM so old
8729    /// snapshots can still resolve the pre-delete row version.
8730    pub(crate) fn finalize_pending_tombstones(&self, conn_id: u64) {
8731        let Some(pending) = self.inner.pending_tombstones.write().remove(&conn_id) else {
8732            return;
8733        };
8734        if pending.is_empty() {
8735            return;
8736        }
8737
8738        let store = self.inner.db.store();
8739        for (collection, id, _xid, _previous_xmax) in pending {
8740            store.context_index().remove_entity(id);
8741            self.cdc_emit(
8742                crate::replication::cdc::ChangeOperation::Delete,
8743                &collection,
8744                id.raw(),
8745                "entity",
8746            );
8747        }
8748    }
8749
8750    /// Revive tombstones on ROLLBACK — reset `xmax` to 0 so the tuples
8751    /// become visible again to future snapshots. Best-effort: a row
8752    /// already reclaimed by a concurrent VACUUM stays gone, but VACUUM
8753    /// never reclaims tuples whose xmax is still referenced by any
8754    /// active snapshot, so this case is only reachable via external
8755    /// storage corruption.
8756    pub(crate) fn revive_pending_tombstones(&self, conn_id: u64) {
8757        let Some(pending) = self.inner.pending_tombstones.write().remove(&conn_id) else {
8758            return;
8759        };
8760
8761        let store = self.inner.db.store();
8762        for (collection, id, xid, previous_xmax) in pending {
8763            let Some(manager) = store.get_collection(&collection) else {
8764                continue;
8765            };
8766            if let Some(mut entity) = manager.get(id) {
8767                if entity.xmax == xid {
8768                    entity.set_xmax(previous_xmax);
8769                    let _ = manager.update(entity);
8770                }
8771            }
8772        }
8773    }
8774
8775    /// Slice C of PRD #718 — accessor for the local wait registry.
8776    pub fn queue_wait_registry(
8777        &self,
8778    ) -> std::sync::Arc<crate::runtime::queue_wait_registry::QueueWaitRegistry> {
8779        self.inner.queue_wait_registry.clone()
8780    }
8781
8782    /// Buffer a `(scope, queue)` wake on the current connection so it
8783    /// fires post-COMMIT, or notify immediately if no transaction is
8784    /// open (autocommit path). The wait registry only ever observes
8785    /// notifies for committed work — rollback drops the buffer.
8786    pub(crate) fn record_queue_wake(&self, scope: &str, queue: &str) {
8787        if self.current_xid().is_some() {
8788            let conn_id = current_connection_id();
8789            self.inner
8790                .pending_queue_wakes
8791                .write()
8792                .entry(conn_id)
8793                .or_default()
8794                .push((scope.to_string(), queue.to_string()));
8795            return;
8796        }
8797        self.inner.queue_wait_registry.notify(scope, queue);
8798    }
8799
8800    pub(crate) fn finalize_pending_queue_wakes(&self, conn_id: u64) {
8801        let Some(pending) = self.inner.pending_queue_wakes.write().remove(&conn_id) else {
8802            return;
8803        };
8804        for (scope, queue) in pending {
8805            self.inner.queue_wait_registry.notify(&scope, &queue);
8806        }
8807    }
8808
8809    pub(crate) fn discard_pending_queue_wakes(&self, conn_id: u64) {
8810        self.inner.pending_queue_wakes.write().remove(&conn_id);
8811    }
8812
8813    pub(crate) fn release_pending_claim_locks(&self, conn_id: u64) {
8814        self.inner
8815            .pending_claim_locks
8816            .write()
8817            .retain(|_, owner| *owner != conn_id);
8818    }
8819
8820    pub(crate) fn finalize_pending_kv_watch_events(&self, conn_id: u64) {
8821        let Some(pending) = self.inner.pending_kv_watch_events.write().remove(&conn_id) else {
8822            return;
8823        };
8824        for event in pending {
8825            self.cdc_emit_kv(
8826                event.op,
8827                &event.collection,
8828                &event.key,
8829                0,
8830                event.before,
8831                event.after,
8832            );
8833        }
8834    }
8835
8836    pub(crate) fn discard_pending_kv_watch_events(&self, conn_id: u64) {
8837        self.inner.pending_kv_watch_events.write().remove(&conn_id);
8838    }
8839
8840    /// Materialise the entire graph store while applying MVCC visibility
8841    /// AND per-collection RLS to each candidate node and edge. Mirrors
8842    /// `materialize_graph` but routes every entity through the same
8843    /// gate the SELECT path uses, with the correct `PolicyTargetKind`
8844    /// per entity kind (`Nodes` for graph nodes, `Edges` for graph
8845    /// edges). Returns the filtered `GraphStore` plus the
8846    /// `node_id → properties` map the executor needs for `RETURN n.*`
8847    /// projections.
8848    fn materialize_graph_with_rls(
8849        &self,
8850    ) -> RedDBResult<(
8851        crate::storage::engine::GraphStore,
8852        std::collections::HashMap<
8853            String,
8854            std::collections::HashMap<String, crate::storage::schema::Value>,
8855        >,
8856        crate::storage::query::unified::EdgeProperties,
8857    )> {
8858        use crate::storage::engine::GraphStore;
8859        use crate::storage::query::ast::{PolicyAction, PolicyTargetKind};
8860        use crate::storage::unified::entity::{EntityData, EntityKind};
8861        use std::collections::{HashMap, HashSet};
8862
8863        let store = self.inner.db.store();
8864        let snap_ctx = capture_current_snapshot();
8865        let role = current_auth_identity().map(|(_, r)| r.as_str().to_string());
8866
8867        let graph = GraphStore::new();
8868        let mut node_properties: HashMap<String, HashMap<String, crate::storage::schema::Value>> =
8869            HashMap::new();
8870        let mut edge_properties: crate::storage::query::unified::EdgeProperties = HashMap::new();
8871        let mut allowed_nodes: HashSet<String> = HashSet::new();
8872
8873        // Per-collection cached compiled filters — Nodes-kind for
8874        // first pass, Edges-kind for the second. None entries mean
8875        // "RLS enabled, zero matching policy → deny all of this kind".
8876        let mut node_rls: HashMap<String, Option<crate::storage::query::ast::Filter>> =
8877            HashMap::new();
8878        let mut edge_rls: HashMap<String, Option<crate::storage::query::ast::Filter>> =
8879            HashMap::new();
8880
8881        let collections = store.list_collections();
8882
8883        // First pass — gather nodes.
8884        for collection in &collections {
8885            let Some(manager) = store.get_collection(collection) else {
8886                continue;
8887            };
8888            let entities = manager.query_all(|_| true);
8889            for entity in entities {
8890                if !entity_visible_with_context(snap_ctx.as_ref(), &entity) {
8891                    continue;
8892                }
8893                let EntityKind::GraphNode(ref node) = entity.kind else {
8894                    continue;
8895                };
8896                if !node_passes_rls(self, collection, role.as_deref(), &mut node_rls, &entity) {
8897                    continue;
8898                }
8899                let id_str = entity.id.raw().to_string();
8900                graph
8901                    .add_node_with_label(
8902                        &id_str,
8903                        &node.label,
8904                        &super::graph_node_label(&node.node_type),
8905                    )
8906                    .map_err(|err| RedDBError::Query(err.to_string()))?;
8907                allowed_nodes.insert(id_str.clone());
8908                if let EntityData::Node(node_data) = &entity.data {
8909                    node_properties.insert(id_str, node_data.properties.clone());
8910                }
8911            }
8912        }
8913
8914        // Second pass — gather edges. An edge appears only when both
8915        // endpoint nodes survived the RLS pass AND the edge itself
8916        // passes its own RLS gate.
8917        for collection in &collections {
8918            let Some(manager) = store.get_collection(collection) else {
8919                continue;
8920            };
8921            let entities = manager.query_all(|_| true);
8922            for entity in entities {
8923                if !entity_visible_with_context(snap_ctx.as_ref(), &entity) {
8924                    continue;
8925                }
8926                let EntityKind::GraphEdge(ref edge) = entity.kind else {
8927                    continue;
8928                };
8929                if !allowed_nodes.contains(&edge.from_node)
8930                    || !allowed_nodes.contains(&edge.to_node)
8931                {
8932                    continue;
8933                }
8934                if !edge_passes_rls(self, collection, role.as_deref(), &mut edge_rls, &entity) {
8935                    continue;
8936                }
8937                let weight = match &entity.data {
8938                    EntityData::Edge(e) => e.weight,
8939                    _ => edge.weight as f32 / 1000.0,
8940                };
8941                let edge_label = super::graph_edge_label(&edge.label);
8942                graph
8943                    .add_edge_with_label(&edge.from_node, &edge.to_node, &edge_label, weight)
8944                    .map_err(|err| RedDBError::Query(err.to_string()))?;
8945                if let EntityData::Edge(edge_data) = &entity.data {
8946                    edge_properties.insert(
8947                        (edge.from_node.clone(), edge_label, edge.to_node.clone()),
8948                        edge_data.properties.clone(),
8949                    );
8950                }
8951            }
8952        }
8953
8954        // Suppress unused-PolicyAction/PolicyTargetKind warnings — both
8955        // are used inside the helper closures via the per-kind helpers
8956        // declared at the bottom of this file.
8957        let _ = (PolicyAction::Select, PolicyTargetKind::Nodes);
8958
8959        Ok((graph, node_properties, edge_properties))
8960    }
8961
8962    /// Phase 1.1 MVCC universal: post-save hook that stamps `xmin` on a
8963    /// freshly-inserted entity when the current connection holds an
8964    /// open transaction. Used by graph / vector / queue / timeseries
8965    /// write paths that go through the DevX builder API (`db.node(...)
8966    /// .save()` and friends) — those live in the storage crate and
8967    /// can't reach `current_xid()` without crossing layers, so the
8968    /// application layer calls this helper right after `save()` to
8969    /// finalise the MVCC stamp.
8970    ///
8971    /// Autocommit (outside BEGIN) is a no-op — no extra lookup or
8972    /// write, so the non-transactional hot path stays untouched.
8973    ///
8974    /// Best-effort: if the collection or entity disappears between
8975    /// the save and the stamp (concurrent DROP), we silently skip.
8976    pub(crate) fn stamp_xmin_if_in_txn(
8977        &self,
8978        collection: &str,
8979        id: crate::storage::unified::entity::EntityId,
8980    ) {
8981        let Some(xid) = self.current_xid() else {
8982            return;
8983        };
8984        let store = self.inner.db.store();
8985        let Some(manager) = store.get_collection(collection) else {
8986            return;
8987        };
8988        if let Some(mut entity) = manager.get(id) {
8989            entity.set_xmin(xid);
8990            let _ = manager.update(entity);
8991        }
8992    }
8993
8994    /// Revive tombstones stamped by `stamper_xid` or any sub-xid
8995    /// allocated after it (Phase 2.3.2e savepoint rollback). Any
8996    /// pending entries with `xid < stamper_xid` stay queued because
8997    /// they belong to the enclosing scope — they'll either flush on
8998    /// COMMIT or revive on an outer ROLLBACK TO SAVEPOINT.
8999    ///
9000    /// Returns the number of tuples whose `xmax` was wiped back to 0.
9001    pub(crate) fn revive_tombstones_since(&self, conn_id: u64, stamper_xid: u64) -> usize {
9002        let mut guard = self.inner.pending_tombstones.write();
9003        let Some(pending) = guard.get_mut(&conn_id) else {
9004            return 0;
9005        };
9006
9007        let store = self.inner.db.store();
9008        let mut revived = 0usize;
9009        pending.retain(|(collection, id, xid, previous_xmax)| {
9010            if *xid < stamper_xid {
9011                // Stamped before the savepoint — keep in queue.
9012                return true;
9013            }
9014            if let Some(manager) = store.get_collection(collection) {
9015                if let Some(mut entity) = manager.get(*id) {
9016                    if entity.xmax == *xid {
9017                        entity.set_xmax(*previous_xmax);
9018                        let _ = manager.update(entity);
9019                        revived += 1;
9020                    }
9021                }
9022            }
9023            false
9024        });
9025        if pending.is_empty() {
9026            guard.remove(&conn_id);
9027        }
9028        revived
9029    }
9030
9031    /// Return the snapshot the current connection should use for visibility
9032    /// checks (Phase 2.3 PG parity).
9033    ///
9034    /// * If the connection is inside a BEGIN-wrapped transaction, reuse
9035    ///   the snapshot stored in its `TxnContext`.
9036    /// * Otherwise (autocommit), capture a fresh snapshot tied to an
9037    ///   implicit xid=0 — the read path treats pre-MVCC rows as always
9038    ///   visible so this degrades to "see everything committed".
9039    pub fn current_snapshot(&self) -> crate::storage::transaction::snapshot::Snapshot {
9040        let conn_id = current_connection_id();
9041        if let Some(ctx) = self.inner.tx_contexts.read().get(&conn_id).cloned() {
9042            return ctx.snapshot;
9043        }
9044        // Autocommit: take a fresh snapshot bounded by `peek_next_xid` so
9045        // every already-committed xid (which is strictly less) passes the
9046        // `xmin <= snap.xid` gate, while concurrently-active xids land in
9047        // the `in_progress` set and stay hidden until they commit. Using
9048        // xid=0 would incorrectly hide every MVCC-stamped tuple.
9049        let high_water = self.inner.snapshot_manager.peek_next_xid();
9050        self.inner.snapshot_manager.snapshot(high_water)
9051    }
9052
9053    /// Xid of the current connection's active transaction, or `None` when
9054    /// running outside a BEGIN/COMMIT block. Write paths call this to
9055    /// decide whether to stamp `xmin`/`xmax` on tuples.
9056    /// Phase 2.3.2e: when a savepoint is open, `writer_xid` returns the
9057    /// sub-xid so new writes can be selectively rolled back. Otherwise
9058    /// the parent txn's xid is returned, matching pre-savepoint
9059    /// behaviour. Callers that need the enclosing *transaction* xid
9060    /// (e.g. VACUUM min-active calculations) should read `ctx.xid`
9061    /// directly.
9062    pub fn current_xid(&self) -> Option<crate::storage::transaction::snapshot::Xid> {
9063        let conn_id = current_connection_id();
9064        self.inner
9065            .tx_contexts
9066            .read()
9067            .get(&conn_id)
9068            .map(|ctx| ctx.writer_xid())
9069    }
9070
9071    /// `true` when the given connection id has an open `BEGIN`. Issue
9072    /// #760 — `OpenStream` consults this to refuse output streams that
9073    /// would otherwise collide with an interactive transaction (see
9074    /// ADR 0029 "Transaction interaction"). HTTP requests pre-dating the
9075    /// connection-id plumbing run with id `0`, which never carries a
9076    /// transaction context, so this returns `false` on those paths.
9077    pub fn connection_in_transaction(&self, conn_id: u64) -> bool {
9078        self.inner.tx_contexts.read().contains_key(&conn_id)
9079    }
9080
9081    /// Access the shared `SnapshotManager` — useful for VACUUM to compute
9082    /// the oldest-active xid when reclaiming dead tuples.
9083    pub fn snapshot_manager(&self) -> Arc<crate::storage::transaction::snapshot::SnapshotManager> {
9084        Arc::clone(&self.inner.snapshot_manager)
9085    }
9086
9087    fn mvcc_vacuum_cutoff_xid(&self) -> crate::storage::transaction::snapshot::Xid {
9088        let manager = &self.inner.snapshot_manager;
9089        let next_xid = manager.peek_next_xid();
9090        let mut cutoff = next_xid;
9091        if let Some(oldest_active) = manager.oldest_active_xid() {
9092            cutoff = cutoff.min(oldest_active);
9093        }
9094        if let Some(oldest_pinned) = manager.oldest_pinned_xid() {
9095            cutoff = cutoff.min(oldest_pinned);
9096        }
9097        let retention_xids = self.config_u64("runtime.mvcc.vacuum_retention_xids", 0);
9098        if retention_xids > 0 {
9099            cutoff = cutoff.min(next_xid.saturating_sub(retention_xids));
9100        }
9101        cutoff
9102    }
9103
9104    fn rebuild_runtime_indexes_for_table(&self, table: &str) -> RedDBResult<()> {
9105        let registered = self.inner.index_store.list_indices(table);
9106        if registered.is_empty() {
9107            return Ok(());
9108        }
9109        let store = self.inner.db.store();
9110        let Some(manager) = store.get_collection(table) else {
9111            return Ok(());
9112        };
9113        let entity_fields = manager
9114            .query_all(|entity| matches!(entity.kind, crate::storage::EntityKind::TableRow { .. }))
9115            .into_iter()
9116            .map(|entity| (entity.id, table_row_index_fields(&entity)))
9117            .collect::<Vec<_>>();
9118
9119        for index in registered {
9120            self.inner.index_store.drop_index(&index.name, table);
9121            self.inner
9122                .index_store
9123                .create_index(
9124                    &index.name,
9125                    table,
9126                    &index.columns,
9127                    index.method,
9128                    index.unique,
9129                    &entity_fields,
9130                )
9131                .map_err(RedDBError::Internal)?;
9132            self.inner.index_store.register(index);
9133        }
9134        self.invalidate_plan_cache();
9135        Ok(())
9136    }
9137
9138    pub(crate) fn persist_runtime_index_descriptor(
9139        &self,
9140        index: super::index_store::RegisteredIndex,
9141    ) -> RedDBResult<()> {
9142        let store = self.inner.db.store();
9143        let _ = store.get_or_create_collection(RUNTIME_INDEX_REGISTRY_COLLECTION);
9144        let entity = crate::storage::UnifiedEntity::new(
9145            crate::storage::EntityId::new(0),
9146            crate::storage::EntityKind::TableRow {
9147                table: std::sync::Arc::from(RUNTIME_INDEX_REGISTRY_COLLECTION),
9148                row_id: 0,
9149            },
9150            crate::storage::EntityData::Row(crate::storage::RowData {
9151                columns: Vec::new(),
9152                named: Some(
9153                    [
9154                        (
9155                            "collection".to_string(),
9156                            crate::storage::schema::Value::text(index.collection.clone()),
9157                        ),
9158                        (
9159                            "name".to_string(),
9160                            crate::storage::schema::Value::text(index.name.clone()),
9161                        ),
9162                        (
9163                            "columns".to_string(),
9164                            crate::storage::schema::Value::text(index.columns.join("\u{1f}")),
9165                        ),
9166                        (
9167                            "method".to_string(),
9168                            crate::storage::schema::Value::text(index_method_kind_as_str(
9169                                index.method,
9170                            )),
9171                        ),
9172                        (
9173                            "unique".to_string(),
9174                            crate::storage::schema::Value::Boolean(index.unique),
9175                        ),
9176                        (
9177                            "dropped".to_string(),
9178                            crate::storage::schema::Value::Boolean(false),
9179                        ),
9180                    ]
9181                    .into_iter()
9182                    .collect(),
9183                ),
9184                schema: None,
9185            }),
9186        );
9187        store
9188            .insert_auto(RUNTIME_INDEX_REGISTRY_COLLECTION, entity)
9189            .map(|_| ())
9190            .map_err(|err| RedDBError::Internal(format!("{err:?}")))
9191    }
9192
9193    pub(crate) fn persist_runtime_index_drop(
9194        &self,
9195        collection: &str,
9196        name: &str,
9197    ) -> RedDBResult<()> {
9198        let store = self.inner.db.store();
9199        let _ = store.get_or_create_collection(RUNTIME_INDEX_REGISTRY_COLLECTION);
9200        let entity = crate::storage::UnifiedEntity::new(
9201            crate::storage::EntityId::new(0),
9202            crate::storage::EntityKind::TableRow {
9203                table: std::sync::Arc::from(RUNTIME_INDEX_REGISTRY_COLLECTION),
9204                row_id: 0,
9205            },
9206            crate::storage::EntityData::Row(crate::storage::RowData {
9207                columns: Vec::new(),
9208                named: Some(
9209                    [
9210                        (
9211                            "collection".to_string(),
9212                            crate::storage::schema::Value::text(collection.to_string()),
9213                        ),
9214                        (
9215                            "name".to_string(),
9216                            crate::storage::schema::Value::text(name.to_string()),
9217                        ),
9218                        (
9219                            "dropped".to_string(),
9220                            crate::storage::schema::Value::Boolean(true),
9221                        ),
9222                    ]
9223                    .into_iter()
9224                    .collect(),
9225                ),
9226                schema: None,
9227            }),
9228        );
9229        store
9230            .insert_auto(RUNTIME_INDEX_REGISTRY_COLLECTION, entity)
9231            .map(|_| ())
9232            .map_err(|err| RedDBError::Internal(format!("{err:?}")))
9233    }
9234
9235    fn rehydrate_runtime_index_registry(&self) -> RedDBResult<()> {
9236        let store = self.inner.db.store();
9237        let Some(manager) = store.get_collection(RUNTIME_INDEX_REGISTRY_COLLECTION) else {
9238            return Ok(());
9239        };
9240        let mut rows = manager.query_all(|_| true);
9241        rows.sort_by_key(|entity| entity.id.raw());
9242
9243        let mut latest = std::collections::HashMap::<
9244            (String, String),
9245            Option<super::index_store::RegisteredIndex>,
9246        >::new();
9247        for entity in rows {
9248            let crate::storage::EntityData::Row(row) = &entity.data else {
9249                continue;
9250            };
9251            let Some(named) = &row.named else {
9252                continue;
9253            };
9254            let Some(collection) = named_text(named, "collection") else {
9255                continue;
9256            };
9257            let Some(name) = named_text(named, "name") else {
9258                continue;
9259            };
9260            let dropped = named_bool(named, "dropped").unwrap_or(false);
9261            let key = (collection.clone(), name.clone());
9262            if dropped {
9263                latest.insert(key, None);
9264                continue;
9265            }
9266            let columns = named_text(named, "columns")
9267                .map(|raw| {
9268                    raw.split('\u{1f}')
9269                        .filter(|part| !part.is_empty())
9270                        .map(str::to_string)
9271                        .collect::<Vec<_>>()
9272                })
9273                .unwrap_or_default();
9274            let Some(method) =
9275                named_text(named, "method").and_then(|raw| index_method_kind_from_str(&raw))
9276            else {
9277                continue;
9278            };
9279            latest.insert(
9280                key,
9281                Some(super::index_store::RegisteredIndex {
9282                    name,
9283                    collection,
9284                    columns,
9285                    method,
9286                    unique: named_bool(named, "unique").unwrap_or(false),
9287                }),
9288            );
9289        }
9290
9291        for index in latest.into_values().flatten() {
9292            let Some(manager) = store.get_collection(&index.collection) else {
9293                continue;
9294            };
9295            let entity_fields = manager
9296                .query_all(|entity| {
9297                    matches!(entity.kind, crate::storage::EntityKind::TableRow { .. })
9298                })
9299                .into_iter()
9300                .map(|entity| (entity.id, table_row_index_fields(&entity)))
9301                .collect::<Vec<_>>();
9302            self.inner
9303                .index_store
9304                .create_index(
9305                    &index.name,
9306                    &index.collection,
9307                    &index.columns,
9308                    index.method,
9309                    index.unique,
9310                    &entity_fields,
9311                )
9312                .map_err(RedDBError::Internal)?;
9313            self.inner.index_store.register(index);
9314        }
9315        self.invalidate_plan_cache();
9316        Ok(())
9317    }
9318
9319    /// Own-tx xids (parent + open/released savepoints) for the current
9320    /// connection. Transports + tests that build a `SnapshotContext`
9321    /// manually (outside the `execute_query` scope) need this set so
9322    /// the writer's own uncommitted tuples stay visible to self.
9323    pub fn current_txn_own_xids(
9324        &self,
9325    ) -> std::collections::HashSet<crate::storage::transaction::snapshot::Xid> {
9326        let mut set = std::collections::HashSet::new();
9327        if let Some(ctx) = self.inner.tx_contexts.read().get(&current_connection_id()) {
9328            set.insert(ctx.xid);
9329            for (_, sub) in &ctx.savepoints {
9330                set.insert(*sub);
9331            }
9332            for sub in &ctx.released_sub_xids {
9333                set.insert(*sub);
9334            }
9335        }
9336        set
9337    }
9338
9339    /// Access the shared `ForeignTableRegistry` (Phase 3.2 PG parity).
9340    ///
9341    /// Callers use this to check whether a table name is a registered
9342    /// foreign table (`registry.is_foreign_table(name)`) and, if so, to
9343    /// scan it (`registry.scan(name)`). The read-path rewriter consults
9344    /// this before dispatching into native-collection lookup.
9345    pub fn foreign_tables(&self) -> Arc<crate::storage::fdw::ForeignTableRegistry> {
9346        Arc::clone(&self.inner.foreign_tables)
9347    }
9348
9349    /// Is Row-Level Security enabled for this table? (Phase 2.5 PG parity)
9350    pub fn is_rls_enabled(&self, table: &str) -> bool {
9351        self.inner.rls_enabled_tables.read().contains(table)
9352    }
9353
9354    /// Collect the USING predicates that apply to this `(table, role, action)`.
9355    ///
9356    /// Returned filters should be OR-combined (a row passes RLS when *any*
9357    /// matching policy accepts it) and then AND-ed into the query's WHERE.
9358    /// When the table has RLS disabled this returns an empty Vec — callers
9359    /// can fast-path back to the unfiltered read.
9360    pub fn matching_rls_policies(
9361        &self,
9362        table: &str,
9363        role: Option<&str>,
9364        action: crate::storage::query::ast::PolicyAction,
9365    ) -> Vec<crate::storage::query::ast::Filter> {
9366        // Default kind = Table preserves the pre-Phase-2.5.5 behaviour:
9367        // callers that don't name a kind only see Table-scoped
9368        // policies (which is what execute SELECT / UPDATE / DELETE
9369        // expect).
9370        self.matching_rls_policies_for_kind(
9371            table,
9372            role,
9373            action,
9374            crate::storage::query::ast::PolicyTargetKind::Table,
9375        )
9376    }
9377
9378    /// Kind-aware variant used by cross-model scans (Phase 2.5.5).
9379    ///
9380    /// Graph scans request `Nodes` / `Edges`, vector ANN requests
9381    /// `Vectors`, queue consumers request `Messages`, and timeseries
9382    /// range scans request `Points`. Policies tagged with a
9383    /// different kind are skipped so a graph-scoped policy doesn't
9384    /// accidentally gate a table SELECT on the same collection.
9385    pub fn matching_rls_policies_for_kind(
9386        &self,
9387        table: &str,
9388        role: Option<&str>,
9389        action: crate::storage::query::ast::PolicyAction,
9390        kind: crate::storage::query::ast::PolicyTargetKind,
9391    ) -> Vec<crate::storage::query::ast::Filter> {
9392        if !self.is_rls_enabled(table) {
9393            return Vec::new();
9394        }
9395        let policies = self.inner.rls_policies.read();
9396        policies
9397            .iter()
9398            .filter_map(|((t, _), p)| {
9399                if t != table {
9400                    return None;
9401                }
9402                // Kind gate — Table policies also apply to every
9403                // other kind *iff* the policy predicate evaluates
9404                // against entity fields that exist uniformly; the
9405                // caller's kind filter is the stricter check, so
9406                // match literally. Auto-tenancy policies stamp
9407                // Table and the caller passes the concrete kind —
9408                // we allow Table policies to apply cross-kind for
9409                // backwards compat.
9410                if p.target_kind != kind
9411                    && p.target_kind != crate::storage::query::ast::PolicyTargetKind::Table
9412                {
9413                    return None;
9414                }
9415                // Action gate — `None` means "ALL" actions.
9416                if let Some(a) = p.action {
9417                    if a != action {
9418                        return None;
9419                    }
9420                }
9421                // Role gate — `None` means "any role".
9422                if let Some(p_role) = p.role.as_deref() {
9423                    match role {
9424                        Some(r) if r == p_role => {}
9425                        _ => return None,
9426                    }
9427                }
9428                Some((*p.using).clone())
9429            })
9430            .collect()
9431    }
9432
9433    pub(crate) fn refresh_table_planner_stats(&self, table: &str) {
9434        let store = self.inner.db.store();
9435        if let Some(stats) =
9436            crate::storage::query::planner::stats_catalog::analyze_collection(store.as_ref(), table)
9437        {
9438            crate::storage::query::planner::stats_catalog::persist_table_stats(
9439                store.as_ref(),
9440                &stats,
9441            );
9442        } else {
9443            crate::storage::query::planner::stats_catalog::clear_table_stats(store.as_ref(), table);
9444        }
9445        self.invalidate_plan_cache();
9446    }
9447
9448    pub(crate) fn note_table_write(&self, table: &str) {
9449        // Skip the write lock when the table is already marked
9450        // dirty. With single-row UPDATEs in a loop this used to
9451        // grab the planner_dirty_tables write lock N times even
9452        // though the first call already flipped the flag.
9453        let already_dirty = self.inner.planner_dirty_tables.read().contains(table);
9454        if !already_dirty {
9455            self.inner
9456                .planner_dirty_tables
9457                .write()
9458                .insert(table.to_string());
9459        }
9460        self.invalidate_result_cache_for_table(table);
9461    }
9462
9463    /// Wrap the planner's `RuntimeQueryExplain` as rows on a
9464    /// `RuntimeQueryResult` so callers over the SQL interface see the
9465    /// plan tree in the same shape a SELECT produces.
9466    ///
9467    /// Columns: `op`, `source`, `est_rows`, `est_cost`, `depth`.
9468    /// Nodes are walked depth-first; `depth` counts from 0 at the
9469    /// root so a text renderer can indent without re-walking.
9470    fn explain_as_rows(&self, raw_query: &str, inner_sql: &str) -> RedDBResult<RuntimeQueryResult> {
9471        let explain = self.explain_query(inner_sql)?;
9472
9473        let columns = vec![
9474            "op".to_string(),
9475            "source".to_string(),
9476            "est_rows".to_string(),
9477            "est_cost".to_string(),
9478            "depth".to_string(),
9479        ];
9480
9481        let mut records: Vec<crate::storage::query::unified::UnifiedRecord> = Vec::new();
9482
9483        // Prepend `CteScan` markers when the query carried a leading
9484        // WITH clause. The CTE bodies are already inlined into the
9485        // main plan tree, but operators reading EXPLAIN need to see
9486        // which named CTEs were resolved — without this row the plan
9487        // would look indistinguishable from a hand-inlined query.
9488        for name in &explain.cte_materializations {
9489            use std::sync::Arc;
9490            let mut rec = crate::storage::query::unified::UnifiedRecord::default();
9491            rec.set_arc(Arc::from("op"), Value::text("CteScan".to_string()));
9492            rec.set_arc(Arc::from("source"), Value::text(name.clone()));
9493            rec.set_arc(Arc::from("est_rows"), Value::Float(0.0));
9494            rec.set_arc(Arc::from("est_cost"), Value::Float(0.0));
9495            rec.set_arc(Arc::from("depth"), Value::Integer(0));
9496            records.push(rec);
9497        }
9498
9499        walk_plan_node(&explain.logical_plan.root, 0, &mut records);
9500
9501        let result = crate::storage::query::unified::UnifiedResult {
9502            columns,
9503            records,
9504            stats: Default::default(),
9505            pre_serialized_json: None,
9506        };
9507
9508        Ok(RuntimeQueryResult {
9509            query: raw_query.to_string(),
9510            mode: explain.mode,
9511            statement: "explain",
9512            engine: "runtime-explain",
9513            result,
9514            affected_rows: 0,
9515            statement_type: "select",
9516            bookmark: None,
9517        })
9518    }
9519
9520    // -----------------------------------------------------------------
9521    // Granular RBAC — privilege gate + GRANT/REVOKE/ALTER USER dispatch
9522    // -----------------------------------------------------------------
9523
9524    /// Project a `QueryExpr` to the (action, resource) pair the
9525    /// privilege engine cares about. Returns `Ok(())` for statements
9526    /// that don't touch user data (transaction control, SHOW, SET, etc.).
9527    pub(crate) fn check_query_privilege(
9528        &self,
9529        expr: &crate::storage::query::ast::QueryExpr,
9530    ) -> Result<(), String> {
9531        use crate::auth::privileges::{Action, AuthzContext, Resource};
9532        use crate::auth::UserId;
9533        use crate::storage::query::ast::QueryExpr;
9534
9535        // No auth store wired (embedded mode / fresh DB / tests) → bypass.
9536        // The bootstrap path itself goes through `execute_query` so this
9537        // is the only sensible default; once auth is wired, the gate
9538        // becomes active.
9539        let auth_store = match self.inner.auth_store.read().clone() {
9540            Some(s) => s,
9541            None => return Ok(()),
9542        };
9543
9544        // Resolve principal + role from the thread-local identity.
9545        // Anonymous (no identity) is allowed to read the bootstrap path
9546        // only when auth_store says so; we treat missing identity as
9547        // platform-admin-equivalent here so embedded test harnesses
9548        // continue to work without setting an identity.
9549        let (username, role) = match current_auth_identity() {
9550            Some(p) => p,
9551            None => return Ok(()),
9552        };
9553        let tenant = current_tenant();
9554
9555        let ctx = AuthzContext {
9556            principal: &username,
9557            effective_role: role,
9558            tenant: tenant.as_deref(),
9559        };
9560        let principal_id = UserId::from_parts(tenant.as_deref(), &username);
9561
9562        // Map QueryExpr → (Action, Resource).
9563        let (action, resource) = match expr {
9564            QueryExpr::Table(t) => (Action::Select, Resource::table_from_name(&t.table)),
9565            QueryExpr::RankOf(_) | QueryExpr::ApproxRankOf(_) | QueryExpr::RankRange(_) => {
9566                (Action::Select, Resource::Database)
9567            }
9568            QueryExpr::QueueSelect(q) => {
9569                return self.check_queue_op_privilege(
9570                    &auth_store,
9571                    &principal_id,
9572                    role,
9573                    tenant.as_deref(),
9574                    "queue:peek",
9575                    &q.queue,
9576                );
9577            }
9578            QueryExpr::QueueCommand(cmd) => {
9579                use crate::storage::query::ast::QueueCommand;
9580                let (queue, action_verb) = match cmd {
9581                    QueueCommand::Push { queue, .. } => (queue.as_str(), "queue:enqueue"),
9582                    QueueCommand::Pop { queue, .. }
9583                    | QueueCommand::GroupRead { queue, .. }
9584                    | QueueCommand::Claim { queue, .. } => (queue.as_str(), "queue:read"),
9585                    QueueCommand::Peek { queue, .. }
9586                    | QueueCommand::Len { queue }
9587                    | QueueCommand::Pending { queue, .. } => (queue.as_str(), "queue:peek"),
9588                    QueueCommand::Ack { queue, .. } => (queue.as_str(), "queue:ack"),
9589                    QueueCommand::Nack {
9590                        queue, delay_ms, ..
9591                    } => {
9592                        // Per-failure retry overrides re-shape retry
9593                        // behaviour for everyone draining the queue and
9594                        // gate on the dedicated `queue:retry` verb so
9595                        // operators can grant base NACK without granting
9596                        // the override capability.
9597                        let verb = if delay_ms.is_some() {
9598                            "queue:retry"
9599                        } else {
9600                            "queue:nack"
9601                        };
9602                        (queue.as_str(), verb)
9603                    }
9604                    QueueCommand::Purge { queue } => (queue.as_str(), "queue:purge"),
9605                    // `GroupCreate` is part of the consumer-setup
9606                    // surface — read-side, never destructive.
9607                    QueueCommand::GroupCreate { queue, .. } => (queue.as_str(), "queue:read"),
9608                    QueueCommand::Move { source, .. } => (source.as_str(), "queue:dlq:move"),
9609                };
9610                return self.check_queue_op_privilege(
9611                    &auth_store,
9612                    &principal_id,
9613                    role,
9614                    tenant.as_deref(),
9615                    action_verb,
9616                    queue,
9617                );
9618            }
9619            QueryExpr::Graph(g) => {
9620                // MATCH … RETURN is the explorer's pattern-traversal
9621                // surface — gate on `graph:traverse` (#757).
9622                self.check_graph_op_privilege(
9623                    &auth_store,
9624                    &principal_id,
9625                    role,
9626                    tenant.as_deref(),
9627                    "graph:traverse",
9628                )?;
9629                if auth_store.iam_authorization_enabled() {
9630                    self.check_graph_property_projection_privilege(
9631                        &auth_store,
9632                        &principal_id,
9633                        role,
9634                        tenant.as_deref(),
9635                        g,
9636                    )?;
9637                    return Ok(());
9638                }
9639                return Ok(());
9640            }
9641            QueryExpr::Path(_) => {
9642                // PATH FROM … TO … is a path-traversal query — gates
9643                // on `graph:traverse` like neighborhood/shortest-path
9644                // (#757).
9645                return self.check_graph_op_privilege(
9646                    &auth_store,
9647                    &principal_id,
9648                    role,
9649                    tenant.as_deref(),
9650                    "graph:traverse",
9651                );
9652            }
9653            QueryExpr::GraphCommand(cmd) => {
9654                use crate::storage::query::ast::GraphCommand;
9655                let action_verb = match cmd {
9656                    // Metadata / property reads.
9657                    GraphCommand::Properties { .. } => "graph:read",
9658                    // Traversal / pattern-walk surface.
9659                    GraphCommand::Neighborhood { .. }
9660                    | GraphCommand::Traverse { .. }
9661                    | GraphCommand::ShortestPath { .. } => "graph:traverse",
9662                    // Analytics algorithms — expensive enough that Red
9663                    // UI needs to gate the runner independently of
9664                    // ordinary traversal.
9665                    GraphCommand::Centrality { .. }
9666                    | GraphCommand::Community { .. }
9667                    | GraphCommand::Components { .. }
9668                    | GraphCommand::Cycles { .. }
9669                    | GraphCommand::Clustering
9670                    | GraphCommand::TopologicalSort => "graph:algorithm:run",
9671                };
9672                return self.check_graph_op_privilege(
9673                    &auth_store,
9674                    &principal_id,
9675                    role,
9676                    tenant.as_deref(),
9677                    action_verb,
9678                );
9679            }
9680            QueryExpr::Vector(v) => {
9681                if auth_store.iam_authorization_enabled() {
9682                    self.check_vector_op_privilege(
9683                        &auth_store,
9684                        &principal_id,
9685                        role,
9686                        tenant.as_deref(),
9687                        "vector:search",
9688                        &v.collection,
9689                    )?;
9690                    self.check_table_like_column_projection_privilege(
9691                        &auth_store,
9692                        &principal_id,
9693                        role,
9694                        tenant.as_deref(),
9695                        &v.collection,
9696                        &["content".to_string()],
9697                    )?;
9698                    return Ok(());
9699                }
9700                return Ok(());
9701            }
9702            QueryExpr::SearchCommand(cmd) => {
9703                use crate::storage::query::ast::SearchCommand;
9704                if auth_store.iam_authorization_enabled() {
9705                    // `SEARCH SIMILAR [..] COLLECTION <c>` and `SEARCH
9706                    // HYBRID ... COLLECTION <c>` are the same UI
9707                    // affordances as `VECTOR SEARCH` / hybrid joins —
9708                    // Red UI must see the same `vector:search` envelope
9709                    // so a single toolbar grant is sufficient.
9710                    let collection = match cmd {
9711                        SearchCommand::Similar { collection, .. }
9712                        | SearchCommand::Hybrid { collection, .. } => Some(collection.as_str()),
9713                        _ => None,
9714                    };
9715                    if let Some(c) = collection {
9716                        self.check_vector_op_privilege(
9717                            &auth_store,
9718                            &principal_id,
9719                            role,
9720                            tenant.as_deref(),
9721                            "vector:search",
9722                            c,
9723                        )?;
9724                        return Ok(());
9725                    }
9726                }
9727                return Ok(());
9728            }
9729            QueryExpr::Hybrid(h) => {
9730                if auth_store.iam_authorization_enabled() {
9731                    // The vector half of a hybrid search is gated under
9732                    // the same `vector:search` verb as a standalone
9733                    // VECTOR SEARCH — Red UI's hybrid-search toolbar
9734                    // must surface the same UI-safe denial envelope
9735                    // when the principal lacks the grant. The
9736                    // structured half is dispatched to its own gate via
9737                    // the inner query during execution.
9738                    self.check_vector_op_privilege(
9739                        &auth_store,
9740                        &principal_id,
9741                        role,
9742                        tenant.as_deref(),
9743                        "vector:search",
9744                        &h.vector.collection,
9745                    )?;
9746                    return Ok(());
9747                }
9748                return Ok(());
9749            }
9750            QueryExpr::Insert(i) => (Action::Insert, Resource::table_from_name(&i.table)),
9751            QueryExpr::Update(u) => (Action::Update, Resource::table_from_name(&u.table)),
9752            QueryExpr::Delete(d) => (Action::Delete, Resource::table_from_name(&d.table)),
9753            // Joins inherit the read privilege from any constituent
9754            // table — for now we emit a single Select on the database
9755            // (admins bypass; non-admins need a Database/Schema grant).
9756            QueryExpr::Join(_) => (Action::Select, Resource::Database),
9757            // GRANT / REVOKE / USER DDL are authority statements;
9758            // require Admin (the helper methods enforce).
9759            QueryExpr::Grant(_)
9760            | QueryExpr::Revoke(_)
9761            | QueryExpr::AlterUser(_)
9762            | QueryExpr::CreateUser(_) => {
9763                return if role == crate::auth::Role::Admin {
9764                    Ok(())
9765                } else {
9766                    Err(format!(
9767                        "principal=`{}` role=`{:?}` cannot issue ACL/auth DDL",
9768                        username, role
9769                    ))
9770                };
9771            }
9772            QueryExpr::CreateIamPolicy { id, .. } => {
9773                return self.check_policy_management_privilege(
9774                    &auth_store,
9775                    &principal_id,
9776                    role,
9777                    tenant.as_deref(),
9778                    "policy:put",
9779                    "policy",
9780                    id,
9781                );
9782            }
9783            QueryExpr::DropIamPolicy { id } => {
9784                return self.check_policy_management_privilege(
9785                    &auth_store,
9786                    &principal_id,
9787                    role,
9788                    tenant.as_deref(),
9789                    "policy:drop",
9790                    "policy",
9791                    id,
9792                );
9793            }
9794            QueryExpr::AttachPolicy { policy_id, .. } => {
9795                return self.check_policy_management_privilege(
9796                    &auth_store,
9797                    &principal_id,
9798                    role,
9799                    tenant.as_deref(),
9800                    "policy:attach",
9801                    "policy",
9802                    policy_id,
9803                );
9804            }
9805            QueryExpr::DetachPolicy { policy_id, .. } => {
9806                return self.check_policy_management_privilege(
9807                    &auth_store,
9808                    &principal_id,
9809                    role,
9810                    tenant.as_deref(),
9811                    "policy:detach",
9812                    "policy",
9813                    policy_id,
9814                );
9815            }
9816            QueryExpr::ShowPolicies { .. } | QueryExpr::ShowEffectivePermissions { .. } => {
9817                return Ok(());
9818            }
9819            QueryExpr::SimulatePolicy { .. } => {
9820                return self.check_policy_management_privilege(
9821                    &auth_store,
9822                    &principal_id,
9823                    role,
9824                    tenant.as_deref(),
9825                    "policy:simulate",
9826                    "policy",
9827                    "*",
9828                );
9829            }
9830            QueryExpr::LintPolicy { .. } => {
9831                // Linting is a read-only inspection — gate it like
9832                // simulate (policy management role).
9833                return self.check_policy_management_privilege(
9834                    &auth_store,
9835                    &principal_id,
9836                    role,
9837                    tenant.as_deref(),
9838                    "policy:simulate",
9839                    "policy",
9840                    "*",
9841                );
9842            }
9843            QueryExpr::MigratePolicyMode { dry_run, .. } => {
9844                // DRY RUN is a pre-flight inspection (policy:simulate).
9845                // The actual mode flip is a privileged mutation under
9846                // the policy:put action (it persists a new enforcement
9847                // mode to the vault KV through `set_enforcement_mode`).
9848                let action = if *dry_run {
9849                    "policy:simulate"
9850                } else {
9851                    "policy:put"
9852                };
9853                return self.check_policy_management_privilege(
9854                    &auth_store,
9855                    &principal_id,
9856                    role,
9857                    tenant.as_deref(),
9858                    action,
9859                    "policy",
9860                    "*",
9861                );
9862            }
9863            // DROP and TRUNCATE — Write-role gate + per-collection IAM policy
9864            // when IAM mode is active. Other DDL stays role-only for now.
9865            QueryExpr::DropTable(q) => {
9866                return self.check_ddl_collection_privilege(
9867                    &auth_store,
9868                    &principal_id,
9869                    role,
9870                    tenant.as_deref(),
9871                    &username,
9872                    "drop",
9873                    &q.name,
9874                );
9875            }
9876            QueryExpr::DropGraph(q) => {
9877                return self.check_ddl_collection_privilege(
9878                    &auth_store,
9879                    &principal_id,
9880                    role,
9881                    tenant.as_deref(),
9882                    &username,
9883                    "drop",
9884                    &q.name,
9885                );
9886            }
9887            QueryExpr::DropVector(q) => {
9888                return self.check_ddl_collection_privilege(
9889                    &auth_store,
9890                    &principal_id,
9891                    role,
9892                    tenant.as_deref(),
9893                    &username,
9894                    "drop",
9895                    &q.name,
9896                );
9897            }
9898            QueryExpr::DropDocument(q) => {
9899                return self.check_ddl_collection_privilege(
9900                    &auth_store,
9901                    &principal_id,
9902                    role,
9903                    tenant.as_deref(),
9904                    &username,
9905                    "drop",
9906                    &q.name,
9907                );
9908            }
9909            QueryExpr::DropKv(q) => {
9910                return self.check_ddl_collection_privilege(
9911                    &auth_store,
9912                    &principal_id,
9913                    role,
9914                    tenant.as_deref(),
9915                    &username,
9916                    "drop",
9917                    &q.name,
9918                );
9919            }
9920            QueryExpr::DropCollection(q) => {
9921                return self.check_ddl_collection_privilege(
9922                    &auth_store,
9923                    &principal_id,
9924                    role,
9925                    tenant.as_deref(),
9926                    &username,
9927                    "drop",
9928                    &q.name,
9929                );
9930            }
9931            QueryExpr::Truncate(q) => {
9932                return self.check_ddl_collection_privilege(
9933                    &auth_store,
9934                    &principal_id,
9935                    role,
9936                    tenant.as_deref(),
9937                    &username,
9938                    "truncate",
9939                    &q.name,
9940                );
9941            }
9942            // Remaining DDL (#753) — hybrid policy-aware gate. Specific
9943            // create/alter/drop verbs gate operations with a clear
9944            // per-collection target so Red UI can author fine-grained
9945            // policies (`create on collection:users`). Namespace-level
9946            // and grouped DDL fall back to broader `schema:admin` /
9947            // `schema:write` verbs against a `schema:<name>` resource.
9948            // All branches share the [`check_ddl_object_privilege`]
9949            // helper so allows / denies produce the same structured
9950            // "principal=… action=… resource=<kind>:<name> denied by
9951            // IAM policy" reason the Red UI security read contracts
9952            // (#740) already render.
9953            QueryExpr::CreateTable(q) => {
9954                return self.check_ddl_object_privilege(
9955                    &auth_store,
9956                    &principal_id,
9957                    role,
9958                    tenant.as_deref(),
9959                    &username,
9960                    "create",
9961                    "collection",
9962                    &q.name,
9963                    crate::auth::Role::Write,
9964                );
9965            }
9966            QueryExpr::CreateCollection(q) => {
9967                return self.check_ddl_object_privilege(
9968                    &auth_store,
9969                    &principal_id,
9970                    role,
9971                    tenant.as_deref(),
9972                    &username,
9973                    "create",
9974                    "collection",
9975                    &q.name,
9976                    crate::auth::Role::Write,
9977                );
9978            }
9979            QueryExpr::CreateVector(q) => {
9980                return self.check_ddl_object_privilege(
9981                    &auth_store,
9982                    &principal_id,
9983                    role,
9984                    tenant.as_deref(),
9985                    &username,
9986                    "create",
9987                    "collection",
9988                    &q.name,
9989                    crate::auth::Role::Write,
9990                );
9991            }
9992            QueryExpr::AlterTable(q) => {
9993                return self.check_ddl_object_privilege(
9994                    &auth_store,
9995                    &principal_id,
9996                    role,
9997                    tenant.as_deref(),
9998                    &username,
9999                    "alter",
10000                    "collection",
10001                    &q.name,
10002                    crate::auth::Role::Write,
10003                );
10004            }
10005            QueryExpr::CreateIndex(q) => {
10006                return self.check_ddl_object_privilege(
10007                    &auth_store,
10008                    &principal_id,
10009                    role,
10010                    tenant.as_deref(),
10011                    &username,
10012                    "create",
10013                    "collection",
10014                    &q.table,
10015                    crate::auth::Role::Write,
10016                );
10017            }
10018            QueryExpr::DropIndex(q) => {
10019                return self.check_ddl_object_privilege(
10020                    &auth_store,
10021                    &principal_id,
10022                    role,
10023                    tenant.as_deref(),
10024                    &username,
10025                    "drop",
10026                    "collection",
10027                    &q.table,
10028                    crate::auth::Role::Write,
10029                );
10030            }
10031            QueryExpr::CreateSchema(q) => {
10032                return self.check_ddl_object_privilege(
10033                    &auth_store,
10034                    &principal_id,
10035                    role,
10036                    tenant.as_deref(),
10037                    &username,
10038                    "schema:admin",
10039                    "schema",
10040                    &q.name,
10041                    crate::auth::Role::Admin,
10042                );
10043            }
10044            QueryExpr::DropSchema(q) => {
10045                return self.check_ddl_object_privilege(
10046                    &auth_store,
10047                    &principal_id,
10048                    role,
10049                    tenant.as_deref(),
10050                    &username,
10051                    "schema:admin",
10052                    "schema",
10053                    &q.name,
10054                    crate::auth::Role::Admin,
10055                );
10056            }
10057            QueryExpr::CreateSequence(q) => {
10058                return self.check_ddl_object_privilege(
10059                    &auth_store,
10060                    &principal_id,
10061                    role,
10062                    tenant.as_deref(),
10063                    &username,
10064                    "create",
10065                    "collection",
10066                    &q.name,
10067                    crate::auth::Role::Write,
10068                );
10069            }
10070            QueryExpr::DropSequence(q) => {
10071                return self.check_ddl_object_privilege(
10072                    &auth_store,
10073                    &principal_id,
10074                    role,
10075                    tenant.as_deref(),
10076                    &username,
10077                    "drop",
10078                    "collection",
10079                    &q.name,
10080                    crate::auth::Role::Write,
10081                );
10082            }
10083            QueryExpr::CreateView(q) => {
10084                return self.check_ddl_object_privilege(
10085                    &auth_store,
10086                    &principal_id,
10087                    role,
10088                    tenant.as_deref(),
10089                    &username,
10090                    "create",
10091                    "collection",
10092                    &q.name,
10093                    crate::auth::Role::Write,
10094                );
10095            }
10096            QueryExpr::DropView(q) => {
10097                return self.check_ddl_object_privilege(
10098                    &auth_store,
10099                    &principal_id,
10100                    role,
10101                    tenant.as_deref(),
10102                    &username,
10103                    "drop",
10104                    "collection",
10105                    &q.name,
10106                    crate::auth::Role::Write,
10107                );
10108            }
10109            QueryExpr::RefreshMaterializedView(q) => {
10110                return self.check_ddl_object_privilege(
10111                    &auth_store,
10112                    &principal_id,
10113                    role,
10114                    tenant.as_deref(),
10115                    &username,
10116                    "alter",
10117                    "collection",
10118                    &q.name,
10119                    crate::auth::Role::Write,
10120                );
10121            }
10122            QueryExpr::CreatePolicy(q) => {
10123                return self.check_ddl_object_privilege(
10124                    &auth_store,
10125                    &principal_id,
10126                    role,
10127                    tenant.as_deref(),
10128                    &username,
10129                    "create",
10130                    "collection",
10131                    &q.table,
10132                    crate::auth::Role::Write,
10133                );
10134            }
10135            QueryExpr::DropPolicy(q) => {
10136                return self.check_ddl_object_privilege(
10137                    &auth_store,
10138                    &principal_id,
10139                    role,
10140                    tenant.as_deref(),
10141                    &username,
10142                    "drop",
10143                    "collection",
10144                    &q.table,
10145                    crate::auth::Role::Write,
10146                );
10147            }
10148            QueryExpr::CreateServer(q) => {
10149                return self.check_ddl_object_privilege(
10150                    &auth_store,
10151                    &principal_id,
10152                    role,
10153                    tenant.as_deref(),
10154                    &username,
10155                    "schema:admin",
10156                    "schema",
10157                    &q.name,
10158                    crate::auth::Role::Admin,
10159                );
10160            }
10161            QueryExpr::DropServer(q) => {
10162                return self.check_ddl_object_privilege(
10163                    &auth_store,
10164                    &principal_id,
10165                    role,
10166                    tenant.as_deref(),
10167                    &username,
10168                    "schema:admin",
10169                    "schema",
10170                    &q.name,
10171                    crate::auth::Role::Admin,
10172                );
10173            }
10174            QueryExpr::CreateForeignTable(q) => {
10175                return self.check_ddl_object_privilege(
10176                    &auth_store,
10177                    &principal_id,
10178                    role,
10179                    tenant.as_deref(),
10180                    &username,
10181                    "schema:write",
10182                    "schema",
10183                    &q.name,
10184                    crate::auth::Role::Write,
10185                );
10186            }
10187            QueryExpr::DropForeignTable(q) => {
10188                return self.check_ddl_object_privilege(
10189                    &auth_store,
10190                    &principal_id,
10191                    role,
10192                    tenant.as_deref(),
10193                    &username,
10194                    "schema:write",
10195                    "schema",
10196                    &q.name,
10197                    crate::auth::Role::Write,
10198                );
10199            }
10200            QueryExpr::CreateTimeSeries(q) => {
10201                return self.check_ddl_object_privilege(
10202                    &auth_store,
10203                    &principal_id,
10204                    role,
10205                    tenant.as_deref(),
10206                    &username,
10207                    "create",
10208                    "collection",
10209                    &q.name,
10210                    crate::auth::Role::Write,
10211                );
10212            }
10213            QueryExpr::CreateMetric(q) => {
10214                return self.check_ddl_object_privilege(
10215                    &auth_store,
10216                    &principal_id,
10217                    role,
10218                    tenant.as_deref(),
10219                    &username,
10220                    "create",
10221                    "collection",
10222                    &q.path,
10223                    crate::auth::Role::Write,
10224                );
10225            }
10226            QueryExpr::AlterMetric(q) => {
10227                return self.check_ddl_object_privilege(
10228                    &auth_store,
10229                    &principal_id,
10230                    role,
10231                    tenant.as_deref(),
10232                    &username,
10233                    "alter",
10234                    "collection",
10235                    &q.path,
10236                    crate::auth::Role::Write,
10237                );
10238            }
10239            QueryExpr::CreateSlo(q) => {
10240                return self.check_ddl_object_privilege(
10241                    &auth_store,
10242                    &principal_id,
10243                    role,
10244                    tenant.as_deref(),
10245                    &username,
10246                    "create",
10247                    "collection",
10248                    &q.path,
10249                    crate::auth::Role::Write,
10250                );
10251            }
10252            QueryExpr::DropTimeSeries(q) => {
10253                return self.check_ddl_object_privilege(
10254                    &auth_store,
10255                    &principal_id,
10256                    role,
10257                    tenant.as_deref(),
10258                    &username,
10259                    "drop",
10260                    "collection",
10261                    &q.name,
10262                    crate::auth::Role::Write,
10263                );
10264            }
10265            QueryExpr::CreateQueue(q) => {
10266                return self.check_ddl_object_privilege(
10267                    &auth_store,
10268                    &principal_id,
10269                    role,
10270                    tenant.as_deref(),
10271                    &username,
10272                    "create",
10273                    "collection",
10274                    &q.name,
10275                    crate::auth::Role::Write,
10276                );
10277            }
10278            QueryExpr::AlterQueue(q) => {
10279                return self.check_ddl_object_privilege(
10280                    &auth_store,
10281                    &principal_id,
10282                    role,
10283                    tenant.as_deref(),
10284                    &username,
10285                    "alter",
10286                    "collection",
10287                    &q.name,
10288                    crate::auth::Role::Write,
10289                );
10290            }
10291            QueryExpr::DropQueue(q) => {
10292                return self.check_ddl_object_privilege(
10293                    &auth_store,
10294                    &principal_id,
10295                    role,
10296                    tenant.as_deref(),
10297                    &username,
10298                    "drop",
10299                    "collection",
10300                    &q.name,
10301                    crate::auth::Role::Write,
10302                );
10303            }
10304            QueryExpr::CreateTree(q) => {
10305                return self.check_ddl_object_privilege(
10306                    &auth_store,
10307                    &principal_id,
10308                    role,
10309                    tenant.as_deref(),
10310                    &username,
10311                    "create",
10312                    "collection",
10313                    &q.collection,
10314                    crate::auth::Role::Write,
10315                );
10316            }
10317            QueryExpr::DropTree(q) => {
10318                return self.check_ddl_object_privilege(
10319                    &auth_store,
10320                    &principal_id,
10321                    role,
10322                    tenant.as_deref(),
10323                    &username,
10324                    "drop",
10325                    "collection",
10326                    &q.collection,
10327                    crate::auth::Role::Write,
10328                );
10329            }
10330            // Migration DDL — CREATE MIGRATION is grouped DDL on the
10331            // schema namespace; uses the `schema:write` fallback verb
10332            // (no obvious per-collection target).
10333            QueryExpr::CreateMigration(q) => {
10334                return self.check_ddl_object_privilege(
10335                    &auth_store,
10336                    &principal_id,
10337                    role,
10338                    tenant.as_deref(),
10339                    &username,
10340                    "schema:write",
10341                    "schema",
10342                    &q.name,
10343                    crate::auth::Role::Write,
10344                );
10345            }
10346            // APPLY / ROLLBACK change data and schema — require Admin.
10347            QueryExpr::ApplyMigration(_) | QueryExpr::RollbackMigration(_) => {
10348                return if role == crate::auth::Role::Admin {
10349                    Ok(())
10350                } else {
10351                    Err(format!(
10352                        "principal=`{}` role=`{:?}` cannot issue APPLY/ROLLBACK MIGRATION",
10353                        username, role
10354                    ))
10355                };
10356            }
10357            // EXPLAIN MIGRATION is read-only — any authenticated principal.
10358            QueryExpr::ExplainMigration(_) => return Ok(()),
10359            // Everything else (SET, SHOW, transaction control, graph
10360            // commands, queue/tree commands, MaintenanceCommand …)
10361            // is allowed for any authenticated principal.
10362            _ => return Ok(()),
10363        };
10364
10365        if auth_store.iam_authorization_enabled() {
10366            let iam_action = legacy_action_to_iam(action);
10367            let iam_resource = legacy_resource_to_iam(&resource, tenant.as_deref());
10368            let iam_ctx = runtime_iam_context(role, tenant.as_deref());
10369            if !auth_store.check_policy_authz_with_role(
10370                &principal_id,
10371                iam_action,
10372                &iam_resource,
10373                &iam_ctx,
10374                role,
10375            ) {
10376                return Err(format!(
10377                    "principal=`{}` action=`{}` resource=`{}:{}` denied by IAM policy",
10378                    username, iam_action, iam_resource.kind, iam_resource.name
10379                ));
10380            }
10381
10382            if let QueryExpr::Table(table) = expr {
10383                self.check_table_column_projection_privilege(
10384                    &auth_store,
10385                    &principal_id,
10386                    &iam_ctx,
10387                    table,
10388                )?;
10389            }
10390
10391            if let QueryExpr::Update(update) = expr {
10392                let columns = update_set_target_columns(update);
10393                if !columns.is_empty() {
10394                    let request = column_access_request_for_table_update(&update.table, columns);
10395                    let outcome =
10396                        auth_store.check_column_projection_authz(&principal_id, &request, &iam_ctx);
10397                    if let Some(denied) = outcome.first_denied_column() {
10398                        return Err(format!(
10399                            "principal=`{}` action=`{}` resource=`{}:{}` denied by IAM column policy",
10400                            username, iam_action, denied.resource.kind, denied.resource.name
10401                        ));
10402                    }
10403                    if !outcome.allowed() {
10404                        return Err(format!(
10405                            "principal=`{}` action=`{}` resource=`{}:{}` denied by IAM policy",
10406                            username,
10407                            iam_action,
10408                            outcome.table_resource.kind,
10409                            outcome.table_resource.name
10410                        ));
10411                    }
10412                }
10413
10414                if let Some(columns) = update_returning_columns_for_policy(self, update) {
10415                    let request = column_access_request_for_table_select(&update.table, columns);
10416                    let outcome =
10417                        auth_store.check_column_projection_authz(&principal_id, &request, &iam_ctx);
10418                    if let Some(denied) = outcome.first_denied_column() {
10419                        return Err(format!(
10420                            "principal=`{}` action=`select` resource=`{}:{}` denied by IAM column policy",
10421                            username, denied.resource.kind, denied.resource.name
10422                        ));
10423                    }
10424                    if !outcome.allowed() {
10425                        return Err(format!(
10426                            "principal=`{}` action=`select` resource=`{}:{}` denied by IAM policy",
10427                            username, outcome.table_resource.kind, outcome.table_resource.name
10428                        ));
10429                    }
10430                }
10431            }
10432
10433            Ok(())
10434        } else {
10435            auth_store
10436                .check_grant(&ctx, action, &resource)
10437                .map_err(|e| e.to_string())
10438        }
10439    }
10440
10441    fn check_table_column_projection_privilege(
10442        &self,
10443        auth_store: &Arc<crate::auth::store::AuthStore>,
10444        principal: &crate::auth::UserId,
10445        ctx: &crate::auth::policies::EvalContext,
10446        table: &crate::storage::query::ast::TableQuery,
10447    ) -> Result<(), String> {
10448        use crate::auth::{ColumnAccessRequest, ColumnDecisionEffect};
10449
10450        let columns = requested_table_columns_for_policy(table);
10451        if columns.is_empty() {
10452            return Ok(());
10453        }
10454
10455        let request = ColumnAccessRequest::select(table.table.clone(), columns);
10456        let outcome = auth_store.check_column_projection_authz(principal, &request, ctx);
10457        if outcome.allowed() {
10458            return Ok(());
10459        }
10460
10461        if !matches!(
10462            outcome.table_decision,
10463            crate::auth::policies::Decision::Allow { .. }
10464                | crate::auth::policies::Decision::AdminBypass
10465        ) {
10466            return Err(format!(
10467                "principal=`{}` action=`select` resource=`{}:{}` denied by IAM policy",
10468                principal, outcome.table_resource.kind, outcome.table_resource.name
10469            ));
10470        }
10471
10472        let denied = outcome
10473            .first_denied_column()
10474            .filter(|decision| decision.effective == ColumnDecisionEffect::Denied);
10475        match denied {
10476            Some(decision) => Err(format!(
10477                "principal=`{}` action=`select` resource=`{}:{}` denied by IAM policy",
10478                principal, decision.resource.kind, decision.resource.name
10479            )),
10480            None => Ok(()),
10481        }
10482    }
10483
10484    fn check_graph_property_projection_privilege(
10485        &self,
10486        auth_store: &Arc<crate::auth::store::AuthStore>,
10487        principal: &crate::auth::UserId,
10488        role: crate::auth::Role,
10489        tenant: Option<&str>,
10490        query: &crate::storage::query::ast::GraphQuery,
10491    ) -> Result<(), String> {
10492        let columns = explicit_graph_projection_properties(query);
10493        if columns.is_empty() {
10494            return Ok(());
10495        }
10496        self.check_table_like_column_projection_privilege(
10497            auth_store, principal, role, tenant, "graph", &columns,
10498        )
10499    }
10500
10501    fn check_table_like_column_projection_privilege(
10502        &self,
10503        auth_store: &Arc<crate::auth::store::AuthStore>,
10504        principal: &crate::auth::UserId,
10505        role: crate::auth::Role,
10506        tenant: Option<&str>,
10507        table: &str,
10508        columns: &[String],
10509    ) -> Result<(), String> {
10510        let iam_ctx = runtime_iam_context(role, tenant);
10511        let request =
10512            crate::auth::ColumnAccessRequest::select(table.to_string(), columns.iter().cloned());
10513        let outcome = auth_store.check_column_projection_authz(principal, &request, &iam_ctx);
10514        if outcome.allowed() {
10515            return Ok(());
10516        }
10517        let denied = outcome
10518            .first_denied_column()
10519            .map(|d| d.resource.name.clone())
10520            .unwrap_or_else(|| format!("{table}.<unknown>"));
10521        Err(format!(
10522            "principal=`{}` action=`select` resource=`column:{}` denied by IAM policy",
10523            principal, denied
10524        ))
10525    }
10526
10527    fn check_policy_management_privilege(
10528        &self,
10529        auth_store: &Arc<crate::auth::store::AuthStore>,
10530        principal: &crate::auth::UserId,
10531        role: crate::auth::Role,
10532        tenant: Option<&str>,
10533        action: &str,
10534        resource_kind: &str,
10535        resource_name: &str,
10536    ) -> Result<(), String> {
10537        let ctx = runtime_iam_context(role, tenant);
10538
10539        if !auth_store.iam_authorization_enabled() {
10540            return if role == crate::auth::Role::Admin {
10541                Ok(())
10542            } else {
10543                Err(format!(
10544                    "principal=`{}` role=`{:?}` cannot issue ACL/auth DDL",
10545                    principal, role
10546                ))
10547            };
10548        }
10549
10550        if resource_kind == "policy"
10551            && matches!(
10552                action,
10553                "policy:put" | "policy:drop" | "policy:attach" | "policy:detach"
10554            )
10555            && self
10556                .inner
10557                .config_registry
10558                .get_active(resource_name)
10559                .map(|entry| entry.managed)
10560                .unwrap_or(false)
10561        {
10562            return Ok(());
10563        }
10564
10565        let mut resource = crate::auth::policies::ResourceRef::new(
10566            resource_kind.to_string(),
10567            resource_name.to_string(),
10568        );
10569        if let Some(t) = tenant {
10570            resource = resource.with_tenant(t.to_string());
10571        }
10572        if auth_store.check_policy_authz_with_role(principal, action, &resource, &ctx, role) {
10573            Ok(())
10574        } else {
10575            Err(format!(
10576                "principal=`{}` action=`{}` resource=`{}:{}` denied by IAM policy",
10577                principal, action, resource.kind, resource.name
10578            ))
10579        }
10580    }
10581
10582    fn check_managed_config_write_for_set_config(&self, key: &str) -> RedDBResult<()> {
10583        let Some(auth_store) = self.inner.auth_store.read().clone() else {
10584            return Ok(());
10585        };
10586        let (username, role) = current_auth_identity()
10587            .unwrap_or_else(|| ("anonymous".to_string(), crate::auth::Role::Read));
10588        let tenant = current_tenant();
10589        let principal = crate::auth::UserId::from_parts(tenant.as_deref(), &username);
10590        let ctx = runtime_iam_context(role, tenant.as_deref());
10591        let gate = crate::auth::managed_config::ManagedConfigGate::new(
10592            self.inner.config_registry.as_ref(),
10593        );
10594        match gate.check_write(&auth_store, &principal, &ctx, key) {
10595            crate::auth::managed_config::ManagedConfigDecision::PassThrough { .. }
10596            | crate::auth::managed_config::ManagedConfigDecision::Allow { .. } => Ok(()),
10597            crate::auth::managed_config::ManagedConfigDecision::Deny { reason, .. } => {
10598                Err(RedDBError::Query(format!(
10599                    "permission denied: managed config mutation blocked for `{key}`: {reason}"
10600                )))
10601            }
10602        }
10603    }
10604
10605    /// IAM privilege check for a granular queue operation (issue #755 /
10606    /// PRD #735).
10607    ///
10608    /// Each queue operation maps to a stable verb in
10609    /// [`crate::auth::action_catalog`] (`queue:enqueue`, `queue:read`,
10610    /// `queue:peek`, `queue:ack`, `queue:nack`, `queue:retry`,
10611    /// `queue:dlq:move`, `queue:purge`, `queue:presence:read`). The
10612    /// resource is `queue:<name>` scoped to the current tenant. In
10613    /// legacy mode (no IAM authorization configured) the check is a
10614    /// no-op — the role gates in `execute_queue_command` still apply
10615    /// and the legacy `select` / `write` grant table continues to
10616    /// govern queue access. In IAM-enabled mode a missing granular
10617    /// grant yields a structured, UI-safe error of the form
10618    /// `principal=… action=queue:… resource=queue:… denied by IAM
10619    /// policy` so Red UI can surface the failing toolbar action.
10620    fn check_queue_op_privilege(
10621        &self,
10622        auth_store: &Arc<crate::auth::store::AuthStore>,
10623        principal: &crate::auth::UserId,
10624        role: crate::auth::Role,
10625        tenant: Option<&str>,
10626        action: &str,
10627        queue: &str,
10628    ) -> Result<(), String> {
10629        if !auth_store.iam_authorization_enabled() {
10630            return Ok(());
10631        }
10632        let mut resource =
10633            crate::auth::policies::ResourceRef::new("queue".to_string(), queue.to_string());
10634        if let Some(t) = tenant {
10635            resource = resource.with_tenant(t.to_string());
10636        }
10637        let ctx = runtime_iam_context(role, tenant);
10638        if auth_store.check_policy_authz_with_role(principal, action, &resource, &ctx, role) {
10639            Ok(())
10640        } else {
10641            Err(format!(
10642                "principal=`{}` action=`{}` resource=`queue:{}` denied by IAM policy",
10643                principal, action, queue
10644            ))
10645        }
10646    }
10647
10648    /// IAM privilege check for a graph operation (issue #757 / PRD
10649    /// #735).
10650    ///
10651    /// Each graph operation maps to a stable verb in
10652    /// [`crate::auth::action_catalog`] — `graph:read` for
10653    /// metadata/property lookups, `graph:traverse` for MATCH / PATH /
10654    /// NEIGHBORHOOD / TRAVERSE / SHORTEST_PATH, and
10655    /// `graph:algorithm:run` for analytics algorithms (centrality,
10656    /// community, components, cycles, clustering, topological sort).
10657    /// The resource is `graph:*` scoped to the current tenant — the
10658    /// runtime today operates on a singleton graph store so the name
10659    /// has no concrete identifier; policies grant the explorer
10660    /// surface by writing `graph:*` as the resource pattern.
10661    ///
10662    /// In legacy mode (no IAM authorization configured) the check is
10663    /// a no-op so the existing role-based defaults continue to
10664    /// govern. In IAM-enabled mode a missing grant produces the
10665    /// UI-safe envelope `principal=… action=graph:… resource=graph:*
10666    /// denied by IAM policy` Red UI keys on.
10667    fn check_graph_op_privilege(
10668        &self,
10669        auth_store: &Arc<crate::auth::store::AuthStore>,
10670        principal: &crate::auth::UserId,
10671        role: crate::auth::Role,
10672        tenant: Option<&str>,
10673        action: &str,
10674    ) -> Result<(), String> {
10675        if !auth_store.iam_authorization_enabled() {
10676            return Ok(());
10677        }
10678        let mut resource =
10679            crate::auth::policies::ResourceRef::new("graph".to_string(), "*".to_string());
10680        if let Some(t) = tenant {
10681            resource = resource.with_tenant(t.to_string());
10682        }
10683        let ctx = runtime_iam_context(role, tenant);
10684        if auth_store.check_policy_authz_with_role(principal, action, &resource, &ctx, role) {
10685            Ok(())
10686        } else {
10687            Err(format!(
10688                "principal=`{}` action=`{}` resource=`graph:*` denied by IAM policy",
10689                principal, action
10690            ))
10691        }
10692    }
10693
10694    /// IAM privilege check for a granular vector operation (issue #756
10695    /// / PRD #735).
10696    ///
10697    /// Each vector operation maps to a stable verb in
10698    /// [`crate::auth::action_catalog`] (`vector:read`, `vector:search`,
10699    /// `vector:artifact:read`, `vector:artifact:rebuild`,
10700    /// `vector:admin`). The resource is `vector:<collection>` scoped to
10701    /// the current tenant. In legacy mode (no IAM authorization
10702    /// configured) the check is a no-op — the role gates and existing
10703    /// `select` / column-projection grants continue to govern access.
10704    /// In IAM-enabled mode a missing granular grant yields a
10705    /// structured, UI-safe error of the form `principal=…
10706    /// action=vector:… resource=vector:… denied by IAM policy` so Red
10707    /// UI can surface the failing toolbar action.
10708    fn check_vector_op_privilege(
10709        &self,
10710        auth_store: &Arc<crate::auth::store::AuthStore>,
10711        principal: &crate::auth::UserId,
10712        role: crate::auth::Role,
10713        tenant: Option<&str>,
10714        action: &str,
10715        collection: &str,
10716    ) -> Result<(), String> {
10717        if !auth_store.iam_authorization_enabled() {
10718            return Ok(());
10719        }
10720        let mut resource =
10721            crate::auth::policies::ResourceRef::new("vector".to_string(), collection.to_string());
10722        if let Some(t) = tenant {
10723            resource = resource.with_tenant(t.to_string());
10724        }
10725        let ctx = runtime_iam_context(role, tenant);
10726        if auth_store.check_policy_authz_with_role(principal, action, &resource, &ctx, role) {
10727            Ok(())
10728        } else {
10729            Err(format!(
10730                "principal=`{}` action=`{}` resource=`vector:{}` denied by IAM policy",
10731                principal, action, collection
10732            ))
10733        }
10734    }
10735
10736    /// IAM privilege check for DROP / TRUNCATE on a named collection.
10737    ///
10738    /// Delegates to [`check_ddl_object_privilege`] with `resource_kind =
10739    /// "collection"`. Kept as a thin wrapper so the existing DROP/TRUNCATE
10740    /// callsites stay readable.
10741    fn check_ddl_collection_privilege(
10742        &self,
10743        auth_store: &Arc<crate::auth::store::AuthStore>,
10744        principal: &crate::auth::UserId,
10745        role: crate::auth::Role,
10746        tenant: Option<&str>,
10747        username: &str,
10748        action: &str,
10749        collection: &str,
10750    ) -> Result<(), String> {
10751        self.check_ddl_object_privilege(
10752            auth_store,
10753            principal,
10754            role,
10755            tenant,
10756            username,
10757            action,
10758            "collection",
10759            collection,
10760            crate::auth::Role::Write,
10761        )
10762    }
10763
10764    /// Generalised IAM privilege check for DDL on a named object.
10765    ///
10766    /// `action` is the stable verb advertised through the action catalog
10767    /// (`create`, `alter`, `drop`, `truncate`, `schema:write`,
10768    /// `schema:admin`). `resource_kind` / `resource_name` form the policy
10769    /// resource (`collection:<name>`, `schema:<name>`). `min_role` is the
10770    /// legacy gate when IAM is not yet enabled.
10771    ///
10772    /// Behaviour:
10773    /// * Role below `min_role` → structured "principal=… role=… cannot
10774    ///   issue DDL" denial, audit recorded.
10775    /// * IAM disabled → audit-record success and allow (legacy path).
10776    /// * IAM enabled → call `check_policy_authz_with_role`. Explicit Deny
10777    ///   and DefaultDeny in PolicyOnly mode both produce a UI-safe
10778    ///   "principal=… action=… resource=<kind>:<name> denied by IAM
10779    ///   policy" string. Explicit Allow and the LegacyRbac fallback
10780    ///   allow the action.
10781    #[allow(clippy::too_many_arguments)]
10782    fn check_ddl_object_privilege(
10783        &self,
10784        auth_store: &Arc<crate::auth::store::AuthStore>,
10785        principal: &crate::auth::UserId,
10786        role: crate::auth::Role,
10787        tenant: Option<&str>,
10788        username: &str,
10789        action: &str,
10790        resource_kind: &str,
10791        resource_name: &str,
10792        min_role: crate::auth::Role,
10793    ) -> Result<(), String> {
10794        if role < min_role {
10795            let msg = format!(
10796                "principal=`{}` role=`{:?}` cannot issue DDL action=`{}` resource=`{}:{}`",
10797                username, role, action, resource_kind, resource_name
10798            );
10799            self.inner.audit_log.record(
10800                action,
10801                username,
10802                resource_name,
10803                "denied",
10804                crate::json::Value::Null,
10805            );
10806            return Err(msg);
10807        }
10808
10809        if !auth_store.iam_authorization_enabled() {
10810            self.inner.audit_log.record(
10811                action,
10812                username,
10813                resource_name,
10814                "ok",
10815                crate::json::Value::Null,
10816            );
10817            return Ok(());
10818        }
10819
10820        let mut resource = crate::auth::policies::ResourceRef::new(
10821            resource_kind.to_string(),
10822            resource_name.to_string(),
10823        );
10824        if let Some(t) = tenant {
10825            resource = resource.with_tenant(t.to_string());
10826        }
10827        let ctx = runtime_iam_context(role, tenant);
10828        if auth_store.check_policy_authz_with_role(principal, action, &resource, &ctx, role) {
10829            self.inner.audit_log.record(
10830                action,
10831                username,
10832                resource_name,
10833                "ok",
10834                crate::json::Value::Null,
10835            );
10836            Ok(())
10837        } else {
10838            self.inner.audit_log.record(
10839                action,
10840                username,
10841                resource_name,
10842                "denied",
10843                crate::json::Value::Null,
10844            );
10845            Err(format!(
10846                "principal=`{}` action=`{}` resource=`{}:{}` denied by IAM policy",
10847                username, action, resource_kind, resource_name
10848            ))
10849        }
10850    }
10851
10852    /// Translate the parsed [`GrantStmt`] into AuthStore mutations.
10853    fn execute_grant_statement(
10854        &self,
10855        query: &str,
10856        stmt: &crate::storage::query::ast::GrantStmt,
10857    ) -> RedDBResult<RuntimeQueryResult> {
10858        use crate::auth::privileges::{Action, GrantPrincipal, Resource};
10859        use crate::auth::UserId;
10860        use crate::storage::query::ast::{GrantObjectKind, GrantPrincipalRef};
10861
10862        let auth_store = self
10863            .inner
10864            .auth_store
10865            .read()
10866            .clone()
10867            .ok_or_else(|| RedDBError::Query("auth store not configured".to_string()))?;
10868
10869        // Granter identity + role.
10870        let (gname, grole) = current_auth_identity().ok_or_else(|| {
10871            RedDBError::Query("GRANT requires an authenticated principal".to_string())
10872        })?;
10873        let granter = UserId::from_parts(current_tenant().as_deref(), &gname);
10874        let granter_role = grole;
10875
10876        // Build the action set.
10877        let mut actions: Vec<Action> = Vec::new();
10878        if stmt.all {
10879            actions.push(Action::All);
10880        } else {
10881            for kw in &stmt.actions {
10882                let a = Action::from_keyword(kw).ok_or_else(|| {
10883                    RedDBError::Query(format!("unknown privilege keyword `{}`", kw))
10884                })?;
10885                actions.push(a);
10886            }
10887        }
10888
10889        // Audit emit (printed; structured emission is Agent #4's lane).
10890        let mut applied = 0usize;
10891        for obj in &stmt.objects {
10892            let resource = match stmt.object_kind {
10893                GrantObjectKind::Table => Resource::Table {
10894                    schema: obj.schema.clone(),
10895                    table: obj.name.clone(),
10896                },
10897                GrantObjectKind::Schema => Resource::Schema(obj.name.clone()),
10898                GrantObjectKind::Database => Resource::Database,
10899                GrantObjectKind::Function => Resource::Function {
10900                    schema: obj.schema.clone(),
10901                    name: obj.name.clone(),
10902                },
10903            };
10904            for principal in &stmt.principals {
10905                let p = match principal {
10906                    GrantPrincipalRef::Public => GrantPrincipal::Public,
10907                    GrantPrincipalRef::Group(g) => GrantPrincipal::Group(g.clone()),
10908                    GrantPrincipalRef::User { tenant, name } => {
10909                        GrantPrincipal::User(UserId::from_parts(tenant.as_deref(), name))
10910                    }
10911                };
10912                // Tenant of the grant follows the granter's tenant
10913                // (cross-tenant guard inside `AuthStore::grant`).
10914                let tenant = granter.tenant.clone();
10915                auth_store
10916                    .grant(
10917                        &granter,
10918                        granter_role,
10919                        p.clone(),
10920                        resource.clone(),
10921                        actions.clone(),
10922                        stmt.with_grant_option,
10923                        tenant.clone(),
10924                    )
10925                    .map_err(|e| RedDBError::Query(e.to_string()))?;
10926
10927                // IAM policy translation: every GRANT also lands as a
10928                // synthetic `_grant_<id>` policy attached to the
10929                // principal so the new evaluator sees it.
10930                if let Some(policy) =
10931                    grant_to_iam_policy(&p, &resource, &actions, tenant.as_deref())
10932                {
10933                    let pid = policy.id.clone();
10934                    auth_store
10935                        .put_policy_internal(policy)
10936                        .map_err(|e| RedDBError::Query(e.to_string()))?;
10937                    let attachment = match &p {
10938                        GrantPrincipal::User(uid) => {
10939                            crate::auth::store::PrincipalRef::User(uid.clone())
10940                        }
10941                        GrantPrincipal::Group(group) => {
10942                            crate::auth::store::PrincipalRef::Group(group.clone())
10943                        }
10944                        GrantPrincipal::Public => crate::auth::store::PrincipalRef::Group(
10945                            crate::auth::store::PUBLIC_IAM_GROUP.to_string(),
10946                        ),
10947                    };
10948                    auth_store
10949                        .attach_policy(attachment, &pid)
10950                        .map_err(|e| RedDBError::Query(e.to_string()))?;
10951                }
10952                applied += 1;
10953                tracing::info!(
10954                    target: "audit",
10955                    principal = %granter,
10956                    action = "grant",
10957                    "GRANT applied"
10958                );
10959            }
10960        }
10961
10962        self.invalidate_result_cache();
10963        Ok(RuntimeQueryResult::ok_message(
10964            query.to_string(),
10965            &format!("GRANT applied to {} target(s)", applied),
10966            "grant",
10967        ))
10968    }
10969
10970    /// Translate the parsed [`RevokeStmt`] into AuthStore mutations.
10971    fn execute_revoke_statement(
10972        &self,
10973        query: &str,
10974        stmt: &crate::storage::query::ast::RevokeStmt,
10975    ) -> RedDBResult<RuntimeQueryResult> {
10976        use crate::auth::privileges::{Action, GrantPrincipal, Resource};
10977        use crate::auth::UserId;
10978        use crate::storage::query::ast::{GrantObjectKind, GrantPrincipalRef};
10979
10980        let auth_store = self
10981            .inner
10982            .auth_store
10983            .read()
10984            .clone()
10985            .ok_or_else(|| RedDBError::Query("auth store not configured".to_string()))?;
10986
10987        let (_gname, grole) = current_auth_identity().ok_or_else(|| {
10988            RedDBError::Query("REVOKE requires an authenticated principal".to_string())
10989        })?;
10990        let granter_role = grole;
10991
10992        let actions: Vec<Action> = if stmt.all {
10993            vec![Action::All]
10994        } else {
10995            stmt.actions
10996                .iter()
10997                .map(|kw| Action::from_keyword(kw).unwrap_or(Action::Select))
10998                .collect()
10999        };
11000
11001        let mut total_removed = 0usize;
11002        for obj in &stmt.objects {
11003            let resource = match stmt.object_kind {
11004                GrantObjectKind::Table => Resource::Table {
11005                    schema: obj.schema.clone(),
11006                    table: obj.name.clone(),
11007                },
11008                GrantObjectKind::Schema => Resource::Schema(obj.name.clone()),
11009                GrantObjectKind::Database => Resource::Database,
11010                GrantObjectKind::Function => Resource::Function {
11011                    schema: obj.schema.clone(),
11012                    name: obj.name.clone(),
11013                },
11014            };
11015            for principal in &stmt.principals {
11016                let p = match principal {
11017                    GrantPrincipalRef::Public => GrantPrincipal::Public,
11018                    GrantPrincipalRef::Group(g) => GrantPrincipal::Group(g.clone()),
11019                    GrantPrincipalRef::User { tenant, name } => {
11020                        GrantPrincipal::User(UserId::from_parts(tenant.as_deref(), name))
11021                    }
11022                };
11023                let removed = auth_store
11024                    .revoke(granter_role, &p, &resource, &actions)
11025                    .map_err(|e| RedDBError::Query(e.to_string()))?;
11026                let _removed_policies =
11027                    auth_store.delete_synthetic_grant_policies(&p, &resource, &actions);
11028                total_removed += removed;
11029            }
11030        }
11031
11032        self.invalidate_result_cache();
11033        Ok(RuntimeQueryResult::ok_message(
11034            query.to_string(),
11035            &format!("REVOKE removed {} grant(s)", total_removed),
11036            "revoke",
11037        ))
11038    }
11039
11040    /// Translate the parsed [`CreateUserStmt`] into an AuthStore user.
11041    fn execute_create_user_statement(
11042        &self,
11043        query: &str,
11044        stmt: &crate::storage::query::ast::CreateUserStmt,
11045    ) -> RedDBResult<RuntimeQueryResult> {
11046        let auth_store = self
11047            .inner
11048            .auth_store
11049            .read()
11050            .clone()
11051            .ok_or_else(|| RedDBError::Query("auth store not configured".to_string()))?;
11052
11053        let (_gname, grole) = current_auth_identity().ok_or_else(|| {
11054            RedDBError::Query("CREATE USER requires an authenticated principal".to_string())
11055        })?;
11056        if grole != crate::auth::Role::Admin {
11057            return Err(RedDBError::Query(
11058                "CREATE USER requires Admin role".to_string(),
11059            ));
11060        }
11061
11062        let role = crate::auth::Role::from_str(&stmt.role)
11063            .ok_or_else(|| RedDBError::Query(format!("invalid role `{}`", stmt.role)))?;
11064        let user = auth_store
11065            .create_user_in_tenant(stmt.tenant.as_deref(), &stmt.username, &stmt.password, role)
11066            .map_err(|e| RedDBError::Query(e.to_string()))?;
11067
11068        self.invalidate_result_cache();
11069        let target = crate::auth::UserId::from_parts(user.tenant_id.as_deref(), &user.username);
11070        tracing::info!(
11071            target: "audit",
11072            principal = %target,
11073            role = %role,
11074            action = "create_user",
11075            "CREATE USER applied"
11076        );
11077
11078        Ok(RuntimeQueryResult::ok_message(
11079            query.to_string(),
11080            &format!("CREATE USER {} applied", target),
11081            "create_user",
11082        ))
11083    }
11084
11085    /// Translate the parsed [`AlterUserStmt`] into AuthStore mutations.
11086    fn execute_alter_user_statement(
11087        &self,
11088        query: &str,
11089        stmt: &crate::storage::query::ast::AlterUserStmt,
11090    ) -> RedDBResult<RuntimeQueryResult> {
11091        use crate::auth::privileges::UserAttributes;
11092        use crate::auth::UserId;
11093        use crate::storage::query::ast::AlterUserAttribute;
11094
11095        let auth_store = self
11096            .inner
11097            .auth_store
11098            .read()
11099            .clone()
11100            .ok_or_else(|| RedDBError::Query("auth store not configured".to_string()))?;
11101
11102        let (_gname, grole) = current_auth_identity().ok_or_else(|| {
11103            RedDBError::Query("ALTER USER requires an authenticated principal".to_string())
11104        })?;
11105        if grole != crate::auth::Role::Admin {
11106            return Err(RedDBError::Query(
11107                "ALTER USER requires Admin role".to_string(),
11108            ));
11109        }
11110
11111        let target = UserId::from_parts(stmt.tenant.as_deref(), &stmt.username);
11112
11113        // Apply attributes incrementally — each one reads the current
11114        // record, mutates the relevant field, writes back.
11115        let mut attrs = auth_store.user_attributes(&target);
11116        let mut enable_change: Option<bool> = None;
11117
11118        for a in &stmt.attributes {
11119            match a {
11120                AlterUserAttribute::ValidUntil(ts) => {
11121                    // Parse ISO-ish timestamp → ms since epoch. Fall
11122                    // back to integer-ms parsing for callers that pass
11123                    // `'1234567890123'`.
11124                    let ms = parse_timestamp_to_ms(ts).ok_or_else(|| {
11125                        RedDBError::Query(format!("invalid VALID UNTIL timestamp `{ts}`"))
11126                    })?;
11127                    attrs.valid_until = Some(ms);
11128                }
11129                AlterUserAttribute::ConnectionLimit(n) => {
11130                    if *n < 0 {
11131                        return Err(RedDBError::Query(
11132                            "CONNECTION LIMIT must be non-negative".to_string(),
11133                        ));
11134                    }
11135                    attrs.connection_limit = Some(*n as u32);
11136                }
11137                AlterUserAttribute::SetSearchPath(p) => {
11138                    attrs.search_path = Some(p.clone());
11139                }
11140                AlterUserAttribute::AddGroup(g) => {
11141                    if !attrs.groups.iter().any(|existing| existing == g) {
11142                        attrs.groups.push(g.clone());
11143                        attrs.groups.sort();
11144                    }
11145                }
11146                AlterUserAttribute::DropGroup(g) => {
11147                    attrs.groups.retain(|existing| existing != g);
11148                }
11149                AlterUserAttribute::Enable => enable_change = Some(true),
11150                AlterUserAttribute::Disable => enable_change = Some(false),
11151                AlterUserAttribute::Password(_) => {
11152                    // Out of scope — accept the AST but no-op so the
11153                    // parser stays compatible with future password
11154                    // rotation work.
11155                }
11156            }
11157        }
11158
11159        auth_store
11160            .set_user_attributes(&target, attrs)
11161            .map_err(|e| RedDBError::Query(e.to_string()))?;
11162        if let Some(en) = enable_change {
11163            auth_store
11164                .set_user_enabled(&target, en)
11165                .map_err(|e| RedDBError::Query(e.to_string()))?;
11166        }
11167        self.invalidate_result_cache();
11168        tracing::info!(
11169            target: "audit",
11170            principal = %target,
11171            action = "alter_user",
11172            "ALTER USER applied"
11173        );
11174
11175        Ok(RuntimeQueryResult::ok_message(
11176            query.to_string(),
11177            &format!("ALTER USER {} applied", target),
11178            "alter_user",
11179        ))
11180    }
11181
11182    // -----------------------------------------------------------------
11183    // IAM policy executors
11184    // -----------------------------------------------------------------
11185
11186    fn execute_create_iam_policy(
11187        &self,
11188        query: &str,
11189        id: &str,
11190        json: &str,
11191    ) -> RedDBResult<RuntimeQueryResult> {
11192        use crate::auth::policies::Policy;
11193
11194        let auth_store = self
11195            .inner
11196            .auth_store
11197            .read()
11198            .clone()
11199            .ok_or_else(|| RedDBError::Query("auth store not configured".to_string()))?;
11200
11201        // Parse + validate. The kernel rejects oversize / bad shape /
11202        // bad action keywords. If the supplied id differs from the JSON
11203        // id, override it with the SQL-provided id (the JSON id is
11204        // optional context — the SQL DDL form is authoritative).
11205        let mut policy = Policy::from_json_str(json)
11206            .map_err(|e| RedDBError::Query(format!("policy parse: {e}")))?;
11207        if policy.id != id {
11208            policy.id = id.to_string();
11209        }
11210        let pid = policy.id.clone();
11211        let tenant = current_tenant();
11212        let (actor_name, actor_role) = current_auth_identity()
11213            .unwrap_or_else(|| ("anonymous".to_string(), crate::auth::Role::Read));
11214        let actor = crate::auth::UserId::from_parts(tenant.as_deref(), &actor_name);
11215        let eval_ctx = runtime_iam_context(actor_role, tenant.as_deref());
11216        let event_ctx = self.policy_mutation_control_ctx(&actor, tenant.as_deref());
11217        let ledger = self.inner.control_event_ledger.read();
11218        let control = crate::auth::store::PolicyMutationControl {
11219            ctx: &event_ctx,
11220            ledger: ledger.as_ref(),
11221            config: self.inner.control_event_config,
11222            registry: Some(self.inner.config_registry.as_ref()),
11223            actor: &actor,
11224            eval_ctx: &eval_ctx,
11225        };
11226        auth_store
11227            .put_policy_with_control_events(policy, &control)
11228            .map_err(|e| RedDBError::Query(e.to_string()))?;
11229
11230        let principal = actor_name;
11231        tracing::info!(
11232            target: "audit",
11233            principal = %principal,
11234            action = "iam:policy.put",
11235            matched_policy_id = %pid,
11236            "CREATE POLICY applied"
11237        );
11238        self.inner.audit_log.record(
11239            "iam/policy.put",
11240            &principal,
11241            &pid,
11242            "ok",
11243            crate::json::Value::Null,
11244        );
11245
11246        self.invalidate_result_cache();
11247        Ok(RuntimeQueryResult::ok_message(
11248            query.to_string(),
11249            &format!("policy `{pid}` stored"),
11250            "create_iam_policy",
11251        ))
11252    }
11253
11254    fn execute_drop_iam_policy(&self, query: &str, id: &str) -> RedDBResult<RuntimeQueryResult> {
11255        let auth_store = self
11256            .inner
11257            .auth_store
11258            .read()
11259            .clone()
11260            .ok_or_else(|| RedDBError::Query("auth store not configured".to_string()))?;
11261        let tenant = current_tenant();
11262        let (actor_name, actor_role) = current_auth_identity()
11263            .unwrap_or_else(|| ("anonymous".to_string(), crate::auth::Role::Read));
11264        let actor = crate::auth::UserId::from_parts(tenant.as_deref(), &actor_name);
11265        let eval_ctx = runtime_iam_context(actor_role, tenant.as_deref());
11266        let event_ctx = self.policy_mutation_control_ctx(&actor, tenant.as_deref());
11267        let ledger = self.inner.control_event_ledger.read();
11268        let control = crate::auth::store::PolicyMutationControl {
11269            ctx: &event_ctx,
11270            ledger: ledger.as_ref(),
11271            config: self.inner.control_event_config,
11272            registry: Some(self.inner.config_registry.as_ref()),
11273            actor: &actor,
11274            eval_ctx: &eval_ctx,
11275        };
11276        auth_store
11277            .delete_policy_with_control_events(id, &control)
11278            .map_err(|e| RedDBError::Query(e.to_string()))?;
11279
11280        let principal = actor_name;
11281        tracing::info!(
11282            target: "audit",
11283            principal = %principal,
11284            action = "iam:policy.drop",
11285            matched_policy_id = %id,
11286            "DROP POLICY applied"
11287        );
11288        self.inner.audit_log.record(
11289            "iam/policy.drop",
11290            &principal,
11291            id,
11292            "ok",
11293            crate::json::Value::Null,
11294        );
11295
11296        self.invalidate_result_cache();
11297        Ok(RuntimeQueryResult::ok_message(
11298            query.to_string(),
11299            &format!("policy `{id}` dropped"),
11300            "drop_iam_policy",
11301        ))
11302    }
11303
11304    fn execute_attach_policy(
11305        &self,
11306        query: &str,
11307        policy_id: &str,
11308        principal: &crate::storage::query::ast::PolicyPrincipalRef,
11309    ) -> RedDBResult<RuntimeQueryResult> {
11310        use crate::auth::store::PrincipalRef;
11311        use crate::auth::UserId;
11312        use crate::storage::query::ast::PolicyPrincipalRef;
11313
11314        let auth_store = self
11315            .inner
11316            .auth_store
11317            .read()
11318            .clone()
11319            .ok_or_else(|| RedDBError::Query("auth store not configured".to_string()))?;
11320        let p = match principal {
11321            PolicyPrincipalRef::User(u) => {
11322                PrincipalRef::User(UserId::from_parts(u.tenant.as_deref(), &u.username))
11323            }
11324            PolicyPrincipalRef::Group(g) => PrincipalRef::Group(g.clone()),
11325        };
11326        let pretty_target = principal_label(principal);
11327        let tenant = current_tenant();
11328        let (actor_name, actor_role) = current_auth_identity()
11329            .unwrap_or_else(|| ("anonymous".to_string(), crate::auth::Role::Read));
11330        let actor = crate::auth::UserId::from_parts(tenant.as_deref(), &actor_name);
11331        let eval_ctx = runtime_iam_context(actor_role, tenant.as_deref());
11332        let event_ctx = self.policy_mutation_control_ctx(&actor, tenant.as_deref());
11333        let ledger = self.inner.control_event_ledger.read();
11334        let control = crate::auth::store::PolicyMutationControl {
11335            ctx: &event_ctx,
11336            ledger: ledger.as_ref(),
11337            config: self.inner.control_event_config,
11338            registry: Some(self.inner.config_registry.as_ref()),
11339            actor: &actor,
11340            eval_ctx: &eval_ctx,
11341        };
11342        auth_store
11343            .attach_policy_with_control_events(p, policy_id, &control)
11344            .map_err(|e| RedDBError::Query(e.to_string()))?;
11345
11346        let principal_str = actor_name;
11347        tracing::info!(
11348            target: "audit",
11349            principal = %principal_str,
11350            action = "iam:policy.attach",
11351            matched_policy_id = %policy_id,
11352            target = %pretty_target,
11353            "ATTACH POLICY applied"
11354        );
11355        self.inner.audit_log.record(
11356            "iam/policy.attach",
11357            &principal_str,
11358            &pretty_target,
11359            "ok",
11360            crate::json::Value::Null,
11361        );
11362
11363        self.invalidate_result_cache();
11364        Ok(RuntimeQueryResult::ok_message(
11365            query.to_string(),
11366            &format!("policy `{policy_id}` attached to {pretty_target}"),
11367            "attach_policy",
11368        ))
11369    }
11370
11371    fn execute_detach_policy(
11372        &self,
11373        query: &str,
11374        policy_id: &str,
11375        principal: &crate::storage::query::ast::PolicyPrincipalRef,
11376    ) -> RedDBResult<RuntimeQueryResult> {
11377        use crate::auth::store::PrincipalRef;
11378        use crate::auth::UserId;
11379        use crate::storage::query::ast::PolicyPrincipalRef;
11380
11381        let auth_store = self
11382            .inner
11383            .auth_store
11384            .read()
11385            .clone()
11386            .ok_or_else(|| RedDBError::Query("auth store not configured".to_string()))?;
11387        let p = match principal {
11388            PolicyPrincipalRef::User(u) => {
11389                PrincipalRef::User(UserId::from_parts(u.tenant.as_deref(), &u.username))
11390            }
11391            PolicyPrincipalRef::Group(g) => PrincipalRef::Group(g.clone()),
11392        };
11393        let pretty_target = principal_label(principal);
11394        let tenant = current_tenant();
11395        let (actor_name, actor_role) = current_auth_identity()
11396            .unwrap_or_else(|| ("anonymous".to_string(), crate::auth::Role::Read));
11397        let actor = crate::auth::UserId::from_parts(tenant.as_deref(), &actor_name);
11398        let eval_ctx = runtime_iam_context(actor_role, tenant.as_deref());
11399        let event_ctx = self.policy_mutation_control_ctx(&actor, tenant.as_deref());
11400        let ledger = self.inner.control_event_ledger.read();
11401        let control = crate::auth::store::PolicyMutationControl {
11402            ctx: &event_ctx,
11403            ledger: ledger.as_ref(),
11404            config: self.inner.control_event_config,
11405            registry: Some(self.inner.config_registry.as_ref()),
11406            actor: &actor,
11407            eval_ctx: &eval_ctx,
11408        };
11409        auth_store
11410            .detach_policy_with_control_events(p, policy_id, &control)
11411            .map_err(|e| RedDBError::Query(e.to_string()))?;
11412
11413        let principal_str = actor_name;
11414        tracing::info!(
11415            target: "audit",
11416            principal = %principal_str,
11417            action = "iam:policy.detach",
11418            matched_policy_id = %policy_id,
11419            target = %pretty_target,
11420            "DETACH POLICY applied"
11421        );
11422        self.inner.audit_log.record(
11423            "iam/policy.detach",
11424            &principal_str,
11425            &pretty_target,
11426            "ok",
11427            crate::json::Value::Null,
11428        );
11429
11430        self.invalidate_result_cache();
11431        Ok(RuntimeQueryResult::ok_message(
11432            query.to_string(),
11433            &format!("policy `{policy_id}` detached from {pretty_target}"),
11434            "detach_policy",
11435        ))
11436    }
11437
11438    fn execute_show_policies(
11439        &self,
11440        query: &str,
11441        filter: Option<&crate::storage::query::ast::PolicyPrincipalRef>,
11442    ) -> RedDBResult<RuntimeQueryResult> {
11443        use crate::auth::UserId;
11444        use crate::storage::query::ast::PolicyPrincipalRef;
11445        use crate::storage::query::unified::UnifiedRecord;
11446        use crate::storage::schema::Value as SchemaValue;
11447        use std::sync::Arc;
11448
11449        let auth_store = self
11450            .inner
11451            .auth_store
11452            .read()
11453            .clone()
11454            .ok_or_else(|| RedDBError::Query("auth store not configured".to_string()))?;
11455
11456        let pols = match filter {
11457            None => auth_store.list_policies(),
11458            Some(PolicyPrincipalRef::User(u)) => {
11459                let id = UserId::from_parts(u.tenant.as_deref(), &u.username);
11460                auth_store.effective_policies(&id)
11461            }
11462            Some(PolicyPrincipalRef::Group(g)) => auth_store.group_policies(g),
11463        };
11464
11465        let mut records = Vec::with_capacity(pols.len() + 1);
11466
11467        // Header row (#712 / S5A): synthetic record at index 0 that
11468        // reports the active PolicyEnforcementMode and the hard-cutover
11469        // version, so an operator running SHOW POLICIES can see the
11470        // current posture without a separate command.
11471        let mode = auth_store.enforcement_mode();
11472        let mut header = UnifiedRecord::default();
11473        header.set_arc(
11474            Arc::from("id"),
11475            SchemaValue::text("<enforcement_mode>".to_string()),
11476        );
11477        header.set_arc(Arc::from("statements"), SchemaValue::Integer(0));
11478        header.set_arc(Arc::from("tenant"), SchemaValue::Null);
11479        let header_json = format!(
11480            r#"{{"enforcement_mode":"{}","policy_only_hard_version":"{}"}}"#,
11481            mode.as_str(),
11482            crate::auth::enforcement_mode::POLICY_ONLY_HARD_VERSION
11483        );
11484        header.set_arc(Arc::from("json"), SchemaValue::text(header_json));
11485        records.push(header);
11486
11487        for p in pols.iter() {
11488            let mut rec = UnifiedRecord::default();
11489            rec.set_arc(Arc::from("id"), SchemaValue::text(p.id.clone()));
11490            rec.set_arc(
11491                Arc::from("statements"),
11492                SchemaValue::Integer(p.statements.len() as i64),
11493            );
11494            rec.set_arc(
11495                Arc::from("tenant"),
11496                p.tenant
11497                    .as_deref()
11498                    .map(|t| SchemaValue::text(t.to_string()))
11499                    .unwrap_or(SchemaValue::Null),
11500            );
11501            rec.set_arc(Arc::from("json"), SchemaValue::text(p.to_json_string()));
11502            records.push(rec);
11503        }
11504        let mut result = crate::storage::query::unified::UnifiedResult::empty();
11505        result.records = records;
11506        Ok(RuntimeQueryResult {
11507            query: query.to_string(),
11508            mode: crate::storage::query::modes::QueryMode::Sql,
11509            statement: "show_policies",
11510            engine: "iam-policies",
11511            result,
11512            affected_rows: 0,
11513            statement_type: "select",
11514            bookmark: None,
11515        })
11516    }
11517
11518    fn execute_show_effective_permissions(
11519        &self,
11520        query: &str,
11521        user: &crate::storage::query::ast::PolicyUserRef,
11522        resource: Option<&crate::storage::query::ast::PolicyResourceRef>,
11523    ) -> RedDBResult<RuntimeQueryResult> {
11524        use crate::auth::UserId;
11525        use crate::storage::query::unified::UnifiedRecord;
11526        use crate::storage::schema::Value as SchemaValue;
11527        use std::sync::Arc;
11528
11529        let auth_store = self
11530            .inner
11531            .auth_store
11532            .read()
11533            .clone()
11534            .ok_or_else(|| RedDBError::Query("auth store not configured".to_string()))?;
11535        let id = UserId::from_parts(user.tenant.as_deref(), &user.username);
11536        let pols = auth_store.effective_policies(&id);
11537
11538        // Show one row per (policy, statement) tuple, plus any
11539        // resource-level filter passed by the caller.
11540        let mut records = Vec::new();
11541        for p in pols.iter() {
11542            for (idx, st) in p.statements.iter().enumerate() {
11543                if let Some(_r) = resource {
11544                    // Naive filter: render statement targets to strings
11545                    // and skip if no match. Conservative default = include
11546                    // (the simulator handles fine-grained matching).
11547                }
11548                let mut rec = UnifiedRecord::default();
11549                rec.set_arc(Arc::from("policy_id"), SchemaValue::text(p.id.clone()));
11550                rec.set_arc(
11551                    Arc::from("statement_index"),
11552                    SchemaValue::Integer(idx as i64),
11553                );
11554                rec.set_arc(
11555                    Arc::from("sid"),
11556                    st.sid
11557                        .as_deref()
11558                        .map(|s| SchemaValue::text(s.to_string()))
11559                        .unwrap_or(SchemaValue::Null),
11560                );
11561                rec.set_arc(
11562                    Arc::from("effect"),
11563                    SchemaValue::text(match st.effect {
11564                        crate::auth::policies::Effect::Allow => "allow",
11565                        crate::auth::policies::Effect::Deny => "deny",
11566                    }),
11567                );
11568                rec.set_arc(
11569                    Arc::from("actions"),
11570                    SchemaValue::Integer(st.actions.len() as i64),
11571                );
11572                rec.set_arc(
11573                    Arc::from("resources"),
11574                    SchemaValue::Integer(st.resources.len() as i64),
11575                );
11576                records.push(rec);
11577            }
11578        }
11579        let mut result = crate::storage::query::unified::UnifiedResult::empty();
11580        result.records = records;
11581        Ok(RuntimeQueryResult {
11582            query: query.to_string(),
11583            mode: crate::storage::query::modes::QueryMode::Sql,
11584            statement: "show_effective_permissions",
11585            engine: "iam-policies",
11586            result,
11587            affected_rows: 0,
11588            statement_type: "select",
11589            bookmark: None,
11590        })
11591    }
11592
11593    fn execute_lint_policy(
11594        &self,
11595        query: &str,
11596        source: &crate::storage::query::ast::LintPolicySource,
11597    ) -> RedDBResult<RuntimeQueryResult> {
11598        use crate::auth::policy_linter::lint;
11599        use crate::storage::query::ast::LintPolicySource;
11600        use crate::storage::query::unified::UnifiedRecord;
11601        use crate::storage::schema::Value as SchemaValue;
11602        use std::sync::Arc;
11603
11604        // Resolve the policy text. `JSON` source lints the literal
11605        // verbatim; `Id` source fetches the stored document so
11606        // operators can lint a policy by name without rebuilding the
11607        // JSON from `SHOW POLICY`.
11608        let policy_text = match source {
11609            LintPolicySource::Json(text) => text.clone(),
11610            LintPolicySource::Id(id) => {
11611                let auth_store =
11612                    self.inner.auth_store.read().clone().ok_or_else(|| {
11613                        RedDBError::Query("auth store not configured".to_string())
11614                    })?;
11615                let policy = auth_store
11616                    .get_policy(id)
11617                    .ok_or_else(|| RedDBError::Query(format!("policy `{id}` not found")))?;
11618                policy.to_json_string()
11619            }
11620        };
11621        let diagnostics = lint(&policy_text);
11622
11623        let principal_str = current_auth_identity()
11624            .map(|(u, _)| u)
11625            .unwrap_or_else(|| "anonymous".into());
11626        tracing::info!(
11627            target: "audit",
11628            principal = %principal_str,
11629            action = "iam:policy.lint",
11630            diagnostic_count = diagnostics.len(),
11631            "LINT POLICY issued"
11632        );
11633        self.inner.audit_log.record(
11634            "iam/policy.lint",
11635            &principal_str,
11636            match source {
11637                LintPolicySource::Id(id) => id.as_str(),
11638                LintPolicySource::Json(_) => "<json>",
11639            },
11640            "ok",
11641            crate::json::Value::Null,
11642        );
11643
11644        // One row per diagnostic. Column order matches the HTTP
11645        // surface's JSON keys so the two contracts line up.
11646        const COLUMNS: [&str; 5] = ["severity", "code", "message", "suggested_fix", "location"];
11647        let schema = Arc::new(
11648            COLUMNS
11649                .iter()
11650                .map(|name| Arc::<str>::from(*name))
11651                .collect::<Vec<_>>(),
11652        );
11653        let records: Vec<UnifiedRecord> = diagnostics
11654            .iter()
11655            .map(|d| {
11656                UnifiedRecord::with_schema(
11657                    Arc::clone(&schema),
11658                    vec![
11659                        SchemaValue::text(d.severity.as_str()),
11660                        SchemaValue::text(d.code.as_str()),
11661                        SchemaValue::text(d.message.clone()),
11662                        d.suggested_fix
11663                            .as_deref()
11664                            .map(SchemaValue::text)
11665                            .unwrap_or(SchemaValue::Null),
11666                        d.location
11667                            .as_deref()
11668                            .map(SchemaValue::text)
11669                            .unwrap_or(SchemaValue::Null),
11670                    ],
11671                )
11672            })
11673            .collect();
11674        let mut result = crate::storage::query::unified::UnifiedResult::with_columns(
11675            COLUMNS.iter().map(|c| c.to_string()).collect(),
11676        );
11677        result.records = records;
11678        Ok(RuntimeQueryResult {
11679            query: query.to_string(),
11680            mode: crate::storage::query::modes::QueryMode::Sql,
11681            statement: "lint_policy",
11682            engine: "iam-policies",
11683            result,
11684            affected_rows: 0,
11685            statement_type: "select",
11686            bookmark: None,
11687        })
11688    }
11689
11690    /// `MIGRATE POLICY MODE TO '<target>' [DRY RUN]` — flip the install
11691    /// from `legacy_rbac` to `policy_only` after the pre-flight delta
11692    /// simulator confirms no non-admin principal would lose access.
11693    /// Issue #714.
11694    fn execute_migrate_policy_mode(
11695        &self,
11696        query: &str,
11697        target: &str,
11698        dry_run: bool,
11699    ) -> RedDBResult<RuntimeQueryResult> {
11700        use crate::auth::enforcement_mode::PolicyEnforcementMode;
11701        use crate::auth::migrate_policy_mode::{
11702            principal_label, simulate_migration_delta, MigratePolicyDelta,
11703        };
11704        use crate::auth::policies::ResourceRef;
11705        use crate::storage::query::unified::UnifiedRecord;
11706        use crate::storage::schema::Value as SchemaValue;
11707        use std::sync::Arc;
11708
11709        // Only `policy_only` is a meaningful destination for this
11710        // command — flipping back to `legacy_rbac` is supported via
11711        // direct config writes (it doesn't need a pre-flight). We
11712        // reject everything else with the same allowlist `parse` uses.
11713        let parsed = PolicyEnforcementMode::parse(target).ok_or_else(|| {
11714            RedDBError::Query(format!(
11715                "MIGRATE POLICY MODE: invalid target `{target}` (expected `policy_only`)"
11716            ))
11717        })?;
11718        if parsed != PolicyEnforcementMode::PolicyOnly {
11719            return Err(RedDBError::Query(format!(
11720                "MIGRATE POLICY MODE: target `{target}` is not supported — only `policy_only` may be migrated to via this command"
11721            )));
11722        }
11723
11724        let auth_store = self
11725            .inner
11726            .auth_store
11727            .read()
11728            .clone()
11729            .ok_or_else(|| RedDBError::Query("auth store not configured".to_string()))?;
11730
11731        // Resource enumeration: every existing collection probed as
11732        // `table:<name>`. This is the realistic resource surface for
11733        // the legacy_rbac fallback (the role floors gate per-table
11734        // actions). Wildcard / column-scoped resources are still
11735        // covered by the policy evaluator because evaluate() resolves
11736        // resource patterns relative to the concrete resources we
11737        // probe here.
11738        let snapshot = self.inner.db.catalog_model_snapshot();
11739        let resources: Vec<ResourceRef> = snapshot
11740            .collections
11741            .iter()
11742            .map(|c| ResourceRef::new("table", c.name.clone()))
11743            .collect();
11744
11745        let now_ms = crate::utils::now_unix_millis() as u128;
11746        let deltas: Vec<MigratePolicyDelta> =
11747            simulate_migration_delta(auth_store.as_ref(), &resources, now_ms);
11748
11749        let principal_str = current_auth_identity()
11750            .map(|(u, _)| u)
11751            .unwrap_or_else(|| "anonymous".into());
11752
11753        // Audit every issuance. The outcome line differentiates
11754        // dry-run, refused, and applied — operators can grep for these
11755        // strings in the audit log.
11756        let outcome_str = if dry_run {
11757            "dry_run"
11758        } else if deltas.is_empty() {
11759            "applied"
11760        } else {
11761            "refused"
11762        };
11763        tracing::info!(
11764            target: "audit",
11765            principal = %principal_str,
11766            action = "iam:policy.migrate_mode",
11767            target = %target,
11768            dry_run,
11769            delta_count = deltas.len(),
11770            outcome = outcome_str,
11771            "MIGRATE POLICY MODE issued"
11772        );
11773        self.inner.audit_log.record(
11774            "iam/policy.migrate_mode",
11775            &principal_str,
11776            target,
11777            outcome_str,
11778            crate::json::Value::Null,
11779        );
11780
11781        // Refuse the non-dry-run path when any principal would lose
11782        // access. The error string carries a compact summary plus the
11783        // delta count so operators can re-run with DRY RUN to inspect.
11784        if !dry_run && !deltas.is_empty() {
11785            let summary = deltas
11786                .iter()
11787                .take(5)
11788                .map(|d| {
11789                    format!(
11790                        "{}:{}/{}:{}",
11791                        principal_label(&d.principal),
11792                        d.action,
11793                        d.resource_kind,
11794                        d.resource_name
11795                    )
11796                })
11797                .collect::<Vec<_>>()
11798                .join(", ");
11799            let more = if deltas.len() > 5 {
11800                format!(" (and {} more)", deltas.len() - 5)
11801            } else {
11802                String::new()
11803            };
11804            return Err(RedDBError::Query(format!(
11805                "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}",
11806                n = deltas.len(),
11807            )));
11808        }
11809
11810        // Mutate the live enforcement mode only on the non-dry-run
11811        // path with an empty delta. `set_enforcement_mode` also
11812        // persists to vault_kv so the new mode survives restart.
11813        if !dry_run {
11814            auth_store.set_enforcement_mode(parsed);
11815        }
11816
11817        const COLUMNS: [&str; 5] = [
11818            "principal",
11819            "role",
11820            "action",
11821            "resource_kind",
11822            "resource_name",
11823        ];
11824        let schema = Arc::new(
11825            COLUMNS
11826                .iter()
11827                .map(|name| Arc::<str>::from(*name))
11828                .collect::<Vec<_>>(),
11829        );
11830        let records: Vec<UnifiedRecord> = deltas
11831            .iter()
11832            .map(|d| {
11833                UnifiedRecord::with_schema(
11834                    Arc::clone(&schema),
11835                    vec![
11836                        SchemaValue::text(principal_label(&d.principal)),
11837                        SchemaValue::text(d.role.as_str()),
11838                        SchemaValue::text(d.action.clone()),
11839                        SchemaValue::text(d.resource_kind.clone()),
11840                        SchemaValue::text(d.resource_name.clone()),
11841                    ],
11842                )
11843            })
11844            .collect();
11845        let mut result = crate::storage::query::unified::UnifiedResult::with_columns(
11846            COLUMNS.iter().map(|c| c.to_string()).collect(),
11847        );
11848        result.records = records;
11849        Ok(RuntimeQueryResult {
11850            query: query.to_string(),
11851            mode: crate::storage::query::modes::QueryMode::Sql,
11852            statement: "migrate_policy_mode",
11853            engine: "iam-policies",
11854            result,
11855            affected_rows: 0,
11856            statement_type: "select",
11857            bookmark: None,
11858        })
11859    }
11860
11861    fn execute_simulate_policy(
11862        &self,
11863        query: &str,
11864        user: &crate::storage::query::ast::PolicyUserRef,
11865        action: &str,
11866        resource: &crate::storage::query::ast::PolicyResourceRef,
11867    ) -> RedDBResult<RuntimeQueryResult> {
11868        use crate::auth::policies::ResourceRef;
11869        use crate::auth::store::SimCtx;
11870        use crate::auth::UserId;
11871        use crate::storage::query::unified::UnifiedRecord;
11872        use crate::storage::schema::Value as SchemaValue;
11873        use std::sync::Arc;
11874
11875        let auth_store = self
11876            .inner
11877            .auth_store
11878            .read()
11879            .clone()
11880            .ok_or_else(|| RedDBError::Query("auth store not configured".to_string()))?;
11881        let id = UserId::from_parts(user.tenant.as_deref(), &user.username);
11882        let r = ResourceRef::new(resource.kind.clone(), resource.name.clone());
11883        let outcome = auth_store.simulate(&id, action, &r, SimCtx::default());
11884
11885        let principal_str = current_auth_identity()
11886            .map(|(u, _)| u)
11887            .unwrap_or_else(|| "anonymous".into());
11888        let (decision_str, matched_pid, matched_sid) = decision_to_strings(&outcome.decision);
11889        tracing::info!(
11890            target: "audit",
11891            principal = %principal_str,
11892            action = "iam:policy.simulate",
11893            decision = %decision_str,
11894            matched_policy_id = ?matched_pid,
11895            matched_sid = ?matched_sid,
11896            "SIMULATE issued"
11897        );
11898        self.inner.audit_log.record(
11899            "iam/policy.simulate",
11900            &principal_str,
11901            &id.to_string(),
11902            "ok",
11903            crate::json::Value::Null,
11904        );
11905
11906        let mut rec = UnifiedRecord::default();
11907        rec.set_arc(Arc::from("decision"), SchemaValue::text(decision_str));
11908        rec.set_arc(
11909            Arc::from("matched_policy_id"),
11910            matched_pid
11911                .map(SchemaValue::text)
11912                .unwrap_or(SchemaValue::Null),
11913        );
11914        rec.set_arc(
11915            Arc::from("matched_sid"),
11916            matched_sid
11917                .map(SchemaValue::text)
11918                .unwrap_or(SchemaValue::Null),
11919        );
11920        rec.set_arc(Arc::from("reason"), SchemaValue::text(outcome.reason));
11921        rec.set_arc(
11922            Arc::from("trail_len"),
11923            SchemaValue::Integer(outcome.trail.len() as i64),
11924        );
11925        let mut result = crate::storage::query::unified::UnifiedResult::empty();
11926        result.records = vec![rec];
11927        Ok(RuntimeQueryResult {
11928            query: query.to_string(),
11929            mode: crate::storage::query::modes::QueryMode::Sql,
11930            statement: "simulate_policy",
11931            engine: "iam-policies",
11932            result,
11933            affected_rows: 0,
11934            statement_type: "select",
11935            bookmark: None,
11936        })
11937    }
11938}
11939
11940/// Translate a parsed GRANT into a synthetic IAM policy whose id
11941/// starts with `_grant_<unique>`. PUBLIC is represented as an
11942/// implicit IAM group; legacy GROUP grants are still rejected by the
11943/// grant store and are not translated here.
11944fn grant_to_iam_policy(
11945    principal: &crate::auth::privileges::GrantPrincipal,
11946    resource: &crate::auth::privileges::Resource,
11947    actions: &[crate::auth::privileges::Action],
11948    tenant: Option<&str>,
11949) -> Option<crate::auth::policies::Policy> {
11950    use crate::auth::policies::{
11951        compile_action, ActionPattern, Effect, Policy, ResourcePattern, Statement,
11952    };
11953    use crate::auth::privileges::{Action, GrantPrincipal, Resource};
11954
11955    if matches!(principal, GrantPrincipal::Group(_)) {
11956        return None;
11957    }
11958
11959    let now = crate::auth::now_ms();
11960    let id = format!("_grant_{:x}_{:x}", now, std::process::id());
11961
11962    let resource_str = match resource {
11963        Resource::Database => "table:*".to_string(),
11964        Resource::Schema(s) => format!("table:{s}.*"),
11965        Resource::Table { schema, table } => match schema {
11966            Some(s) => format!("table:{s}.{table}"),
11967            None => format!("table:{table}"),
11968        },
11969        Resource::Function { schema, name } => match schema {
11970            Some(s) => format!("function:{s}.{name}"),
11971            None => format!("function:{name}"),
11972        },
11973    };
11974
11975    // Compile actions — fall back to `*` only when the grant included
11976    // `Action::All`. Map every other action keyword to its lowercase
11977    // form so it lines up with the kernel's allowlist.
11978    let action_patterns: Vec<ActionPattern> = if actions.contains(&Action::All) {
11979        vec![ActionPattern::Wildcard]
11980    } else {
11981        actions
11982            .iter()
11983            .map(|a| compile_action(&a.as_str().to_ascii_lowercase()))
11984            .collect()
11985    };
11986    if action_patterns.is_empty() {
11987        return None;
11988    }
11989
11990    // Inline resource compilation matching the kernel's `compile_resource`:
11991    //   * `*` → wildcard
11992    //   * contains `*` → glob
11993    //   * `kind:name` → exact
11994    let resource_patterns = if resource_str == "*" {
11995        vec![ResourcePattern::Wildcard]
11996    } else if resource_str.contains('*') {
11997        vec![ResourcePattern::Glob(resource_str.clone())]
11998    } else if let Some((kind, name)) = resource_str.split_once(':') {
11999        vec![ResourcePattern::Exact {
12000            kind: kind.to_string(),
12001            name: name.to_string(),
12002        }]
12003    } else {
12004        vec![ResourcePattern::Wildcard]
12005    };
12006
12007    let policy = Policy {
12008        id,
12009        version: 1,
12010        tenant: tenant.map(|t| t.to_string()),
12011        created_at: now,
12012        updated_at: now,
12013        statements: vec![Statement {
12014            sid: None,
12015            effect: Effect::Allow,
12016            actions: action_patterns,
12017            resources: resource_patterns,
12018            condition: None,
12019        }],
12020    };
12021    if policy.validate().is_err() {
12022        return None;
12023    }
12024    Some(policy)
12025}
12026
12027/// Coerce a `key => <number>` table-function named argument into a positive
12028/// iteration count for the centrality TVFs (issue #797). The parser lexes all
12029/// named values as `f64`, so an integral, finite, strictly-positive value is
12030/// required here; anything else (fractional, zero, negative, NaN/inf) is a
12031/// clear query error. `func` names the function for the message.
12032fn parse_positive_iterations(func: &str, value: &f64) -> RedDBResult<usize> {
12033    if !value.is_finite() || *value < 1.0 || value.fract() != 0.0 {
12034        return Err(RedDBError::Query(format!(
12035            "table function '{func}' max_iterations must be a positive integer, got {value}"
12036        )));
12037    }
12038    Ok(*value as usize)
12039}
12040
12041fn legacy_action_to_iam(action: crate::auth::privileges::Action) -> &'static str {
12042    use crate::auth::privileges::Action;
12043    match action {
12044        Action::Select => "select",
12045        Action::Insert => "insert",
12046        Action::Update => "update",
12047        Action::Delete => "delete",
12048        Action::Truncate => "truncate",
12049        Action::References => "references",
12050        Action::Execute => "execute",
12051        Action::Usage => "usage",
12052        Action::All => "*",
12053    }
12054}
12055
12056fn update_set_target_columns(query: &crate::storage::query::ast::UpdateQuery) -> Vec<String> {
12057    let mut columns = Vec::new();
12058    for (column, _) in &query.assignment_exprs {
12059        if !columns.iter().any(|seen| seen == column) {
12060            columns.push(column.clone());
12061        }
12062    }
12063    columns
12064}
12065
12066fn column_access_request_for_table_update(
12067    table_name: &str,
12068    columns: Vec<String>,
12069) -> crate::auth::ColumnAccessRequest {
12070    match table_name.split_once('.') {
12071        Some((schema, table)) => {
12072            crate::auth::ColumnAccessRequest::update(table.to_string(), columns)
12073                .with_schema(schema.to_string())
12074        }
12075        None => crate::auth::ColumnAccessRequest::update(table_name.to_string(), columns),
12076    }
12077}
12078
12079fn column_access_request_for_table_select(
12080    table_name: &str,
12081    columns: Vec<String>,
12082) -> crate::auth::ColumnAccessRequest {
12083    match table_name.split_once('.') {
12084        Some((schema, table)) => {
12085            crate::auth::ColumnAccessRequest::select(table.to_string(), columns)
12086                .with_schema(schema.to_string())
12087        }
12088        None => crate::auth::ColumnAccessRequest::select(table_name.to_string(), columns),
12089    }
12090}
12091
12092fn update_returning_columns_for_policy(
12093    runtime: &RedDBRuntime,
12094    query: &crate::storage::query::ast::UpdateQuery,
12095) -> Option<Vec<String>> {
12096    let items = query.returning.as_ref()?;
12097    let mut columns = Vec::new();
12098    let project_all = items
12099        .iter()
12100        .any(|item| matches!(item, crate::storage::query::ast::ReturningItem::All));
12101    if project_all {
12102        collect_returning_star_columns(runtime, query, &mut columns);
12103    } else {
12104        for item in items {
12105            let crate::storage::query::ast::ReturningItem::Column(column) = item else {
12106                continue;
12107            };
12108            push_returning_policy_column(&mut columns, column);
12109        }
12110    }
12111    (!columns.is_empty()).then_some(columns)
12112}
12113
12114fn collect_returning_star_columns(
12115    runtime: &RedDBRuntime,
12116    query: &crate::storage::query::ast::UpdateQuery,
12117    columns: &mut Vec<String>,
12118) {
12119    let store = runtime.db().store();
12120    let Some(manager) = store.get_collection(&query.table) else {
12121        return;
12122    };
12123    if let Some(schema) = manager.column_schema() {
12124        for column in schema.iter() {
12125            push_returning_policy_column(columns, column);
12126        }
12127    }
12128    for entity in manager.query_all(|_| true) {
12129        if !returning_entity_matches_update_target(&entity, query.target) {
12130            continue;
12131        }
12132        match &entity.data {
12133            crate::storage::EntityData::Row(row) => {
12134                for (column, _) in row.iter_fields() {
12135                    push_returning_policy_column(columns, column);
12136                }
12137            }
12138            crate::storage::EntityData::Node(node) => {
12139                push_returning_policy_column(columns, "label");
12140                push_returning_policy_column(columns, "node_type");
12141                for column in node.properties.keys() {
12142                    push_returning_policy_column(columns, column);
12143                }
12144            }
12145            crate::storage::EntityData::Edge(edge) => {
12146                push_returning_policy_column(columns, "label");
12147                push_returning_policy_column(columns, "from_rid");
12148                push_returning_policy_column(columns, "to_rid");
12149                push_returning_policy_column(columns, "weight");
12150                for column in edge.properties.keys() {
12151                    push_returning_policy_column(columns, column);
12152                }
12153            }
12154            _ => {}
12155        }
12156    }
12157}
12158
12159fn push_returning_policy_column(columns: &mut Vec<String>, column: &str) {
12160    if returning_public_envelope_column(column) {
12161        return;
12162    }
12163    if !columns.iter().any(|seen| seen == column) {
12164        columns.push(column.to_string());
12165    }
12166}
12167
12168fn returning_public_envelope_column(column: &str) -> bool {
12169    matches!(
12170        column.to_ascii_lowercase().as_str(),
12171        "rid" | "collection" | "kind" | "tenant" | "created_at" | "updated_at"
12172    )
12173}
12174
12175fn returning_entity_matches_update_target(
12176    entity: &crate::storage::UnifiedEntity,
12177    target: crate::storage::query::ast::UpdateTarget,
12178) -> bool {
12179    use crate::storage::query::ast::UpdateTarget;
12180    match target {
12181        UpdateTarget::Rows => {
12182            matches!(returning_row_item_kind(entity), Some(ReturningRowKind::Row))
12183        }
12184        UpdateTarget::Documents => {
12185            matches!(
12186                returning_row_item_kind(entity),
12187                Some(ReturningRowKind::Document)
12188            )
12189        }
12190        UpdateTarget::Kv => matches!(returning_row_item_kind(entity), Some(ReturningRowKind::Kv)),
12191        UpdateTarget::Nodes => matches!(
12192            (&entity.kind, &entity.data),
12193            (
12194                crate::storage::EntityKind::GraphNode(_),
12195                crate::storage::EntityData::Node(_)
12196            )
12197        ),
12198        UpdateTarget::Edges => matches!(
12199            (&entity.kind, &entity.data),
12200            (
12201                crate::storage::EntityKind::GraphEdge(_),
12202                crate::storage::EntityData::Edge(_)
12203            )
12204        ),
12205    }
12206}
12207
12208#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12209enum ReturningRowKind {
12210    Row,
12211    Document,
12212    Kv,
12213}
12214
12215fn returning_row_item_kind(entity: &crate::storage::UnifiedEntity) -> Option<ReturningRowKind> {
12216    let row = entity.data.as_row()?;
12217    let is_kv = row.iter_fields().all(|(column, _)| {
12218        column.eq_ignore_ascii_case("key") || column.eq_ignore_ascii_case("value")
12219    });
12220    if is_kv {
12221        return Some(ReturningRowKind::Kv);
12222    }
12223    let is_document = row
12224        .iter_fields()
12225        .any(|(_, value)| matches!(value, crate::storage::schema::Value::Json(_)));
12226    if is_document {
12227        Some(ReturningRowKind::Document)
12228    } else {
12229        Some(ReturningRowKind::Row)
12230    }
12231}
12232
12233fn requested_table_columns_for_policy(
12234    table: &crate::storage::query::ast::TableQuery,
12235) -> Vec<String> {
12236    use crate::storage::query::sql_lowering::{
12237        effective_table_filter, effective_table_group_by_exprs, effective_table_having_filter,
12238        effective_table_projections,
12239    };
12240
12241    let table_name = table.table.as_str();
12242    let table_alias = table.alias.as_deref();
12243    let mut columns = std::collections::BTreeSet::new();
12244
12245    for projection in effective_table_projections(table) {
12246        collect_projection_columns(&projection, table_name, table_alias, &mut columns);
12247    }
12248    if let Some(filter) = effective_table_filter(table) {
12249        collect_filter_columns(&filter, table_name, table_alias, &mut columns);
12250    }
12251    for expr in effective_table_group_by_exprs(table) {
12252        collect_expr_columns(&expr, table_name, table_alias, &mut columns);
12253    }
12254    if let Some(filter) = effective_table_having_filter(table) {
12255        collect_filter_columns(&filter, table_name, table_alias, &mut columns);
12256    }
12257    for order in &table.order_by {
12258        if let Some(expr) = order.expr.as_ref() {
12259            collect_expr_columns(expr, table_name, table_alias, &mut columns);
12260        } else {
12261            collect_field_ref_column(&order.field, table_name, table_alias, &mut columns);
12262        }
12263    }
12264
12265    columns.into_iter().collect()
12266}
12267
12268fn collect_projection_columns(
12269    projection: &crate::storage::query::ast::Projection,
12270    table_name: &str,
12271    table_alias: Option<&str>,
12272    columns: &mut std::collections::BTreeSet<String>,
12273) {
12274    use crate::storage::query::ast::Projection;
12275    match projection {
12276        Projection::All => {
12277            columns.insert("*".to_string());
12278        }
12279        Projection::Column(column) | Projection::Alias(column, _) => {
12280            if column != "*" {
12281                columns.insert(column.clone());
12282            }
12283        }
12284        Projection::Function(_, args) => {
12285            for arg in args {
12286                collect_projection_columns(arg, table_name, table_alias, columns);
12287            }
12288        }
12289        Projection::Expression(filter, _) => {
12290            collect_filter_columns(filter, table_name, table_alias, columns);
12291        }
12292        Projection::Field(field, _) => {
12293            collect_field_ref_column(field, table_name, table_alias, columns);
12294        }
12295        // Slice 7a (#589): no runtime support yet; recurse into args so
12296        // any column references are still tracked in case a future
12297        // executor needs the column set.
12298        Projection::Window { args, .. } => {
12299            for arg in args {
12300                collect_projection_columns(arg, table_name, table_alias, columns);
12301            }
12302        }
12303    }
12304}
12305
12306fn collect_filter_columns(
12307    filter: &crate::storage::query::ast::Filter,
12308    table_name: &str,
12309    table_alias: Option<&str>,
12310    columns: &mut std::collections::BTreeSet<String>,
12311) {
12312    use crate::storage::query::ast::Filter;
12313    match filter {
12314        Filter::Compare { field, .. }
12315        | Filter::IsNull(field)
12316        | Filter::IsNotNull(field)
12317        | Filter::In { field, .. }
12318        | Filter::Between { field, .. }
12319        | Filter::Like { field, .. }
12320        | Filter::StartsWith { field, .. }
12321        | Filter::EndsWith { field, .. }
12322        | Filter::Contains { field, .. } => {
12323            collect_field_ref_column(field, table_name, table_alias, columns);
12324        }
12325        Filter::CompareFields { left, right, .. } => {
12326            collect_field_ref_column(left, table_name, table_alias, columns);
12327            collect_field_ref_column(right, table_name, table_alias, columns);
12328        }
12329        Filter::CompareExpr { lhs, rhs, .. } => {
12330            collect_expr_columns(lhs, table_name, table_alias, columns);
12331            collect_expr_columns(rhs, table_name, table_alias, columns);
12332        }
12333        Filter::And(left, right) | Filter::Or(left, right) => {
12334            collect_filter_columns(left, table_name, table_alias, columns);
12335            collect_filter_columns(right, table_name, table_alias, columns);
12336        }
12337        Filter::Not(inner) => collect_filter_columns(inner, table_name, table_alias, columns),
12338    }
12339}
12340
12341fn collect_expr_columns(
12342    expr: &crate::storage::query::ast::Expr,
12343    table_name: &str,
12344    table_alias: Option<&str>,
12345    columns: &mut std::collections::BTreeSet<String>,
12346) {
12347    use crate::storage::query::ast::Expr;
12348    match expr {
12349        Expr::Column { field, .. } => {
12350            collect_field_ref_column(field, table_name, table_alias, columns);
12351        }
12352        Expr::Literal { .. } | Expr::Parameter { .. } => {}
12353        Expr::UnaryOp { operand, .. } | Expr::Cast { inner: operand, .. } => {
12354            collect_expr_columns(operand, table_name, table_alias, columns);
12355        }
12356        Expr::BinaryOp { lhs, rhs, .. } => {
12357            collect_expr_columns(lhs, table_name, table_alias, columns);
12358            collect_expr_columns(rhs, table_name, table_alias, columns);
12359        }
12360        Expr::FunctionCall { args, .. } => {
12361            for arg in args {
12362                collect_expr_columns(arg, table_name, table_alias, columns);
12363            }
12364        }
12365        Expr::Case {
12366            branches, else_, ..
12367        } => {
12368            for (condition, value) in branches {
12369                collect_expr_columns(condition, table_name, table_alias, columns);
12370                collect_expr_columns(value, table_name, table_alias, columns);
12371            }
12372            if let Some(value) = else_ {
12373                collect_expr_columns(value, table_name, table_alias, columns);
12374            }
12375        }
12376        Expr::IsNull { operand, .. } => {
12377            collect_expr_columns(operand, table_name, table_alias, columns);
12378        }
12379        Expr::InList { target, values, .. } => {
12380            collect_expr_columns(target, table_name, table_alias, columns);
12381            for value in values {
12382                collect_expr_columns(value, table_name, table_alias, columns);
12383            }
12384        }
12385        Expr::Between {
12386            target, low, high, ..
12387        } => {
12388            collect_expr_columns(target, table_name, table_alias, columns);
12389            collect_expr_columns(low, table_name, table_alias, columns);
12390            collect_expr_columns(high, table_name, table_alias, columns);
12391        }
12392        Expr::Subquery { .. } => {}
12393        Expr::WindowFunctionCall { args, window, .. } => {
12394            for arg in args {
12395                collect_expr_columns(arg, table_name, table_alias, columns);
12396            }
12397            for e in &window.partition_by {
12398                collect_expr_columns(e, table_name, table_alias, columns);
12399            }
12400            for o in &window.order_by {
12401                collect_expr_columns(&o.expr, table_name, table_alias, columns);
12402            }
12403        }
12404    }
12405}
12406
12407fn collect_field_ref_column(
12408    field: &crate::storage::query::ast::FieldRef,
12409    table_name: &str,
12410    table_alias: Option<&str>,
12411    columns: &mut std::collections::BTreeSet<String>,
12412) {
12413    if let Some(column) = policy_column_name_from_field_ref(field, table_name, table_alias) {
12414        if column != "*" {
12415            columns.insert(column);
12416        }
12417    }
12418}
12419
12420fn policy_column_name_from_field_ref(
12421    field: &crate::storage::query::ast::FieldRef,
12422    table_name: &str,
12423    table_alias: Option<&str>,
12424) -> Option<String> {
12425    match field {
12426        crate::storage::query::ast::FieldRef::TableColumn { table, column } => {
12427            if column == "*" {
12428                return Some("*".to_string());
12429            }
12430            if table.is_empty() || table == table_name || Some(table.as_str()) == table_alias {
12431                Some(column.clone())
12432            } else {
12433                Some(format!("{table}.{column}"))
12434            }
12435        }
12436        _ => None,
12437    }
12438}
12439
12440fn legacy_resource_to_iam(
12441    resource: &crate::auth::privileges::Resource,
12442    tenant: Option<&str>,
12443) -> crate::auth::policies::ResourceRef {
12444    use crate::auth::privileges::Resource;
12445
12446    let (kind, name) = match resource {
12447        Resource::Database => ("database".to_string(), "*".to_string()),
12448        Resource::Schema(s) => ("schema".to_string(), format!("{s}.*")),
12449        Resource::Table { schema, table } => (
12450            "table".to_string(),
12451            match schema {
12452                Some(s) => format!("{s}.{table}"),
12453                None => table.clone(),
12454            },
12455        ),
12456        Resource::Function { schema, name } => (
12457            "function".to_string(),
12458            match schema {
12459                Some(s) => format!("{s}.{name}"),
12460                None => name.clone(),
12461            },
12462        ),
12463    };
12464
12465    let mut out = crate::auth::policies::ResourceRef::new(kind, name);
12466    if let Some(t) = tenant {
12467        out = out.with_tenant(t.to_string());
12468    }
12469    out
12470}
12471
12472#[derive(Debug)]
12473struct JoinTableSide {
12474    table: String,
12475    alias: String,
12476}
12477
12478fn table_side_context(expr: &QueryExpr) -> Option<JoinTableSide> {
12479    match expr {
12480        QueryExpr::Table(table) => Some(JoinTableSide {
12481            table: table.table.clone(),
12482            alias: table.alias.clone().unwrap_or_else(|| table.table.clone()),
12483        }),
12484        _ => None,
12485    }
12486}
12487
12488fn collect_projection_columns_for_table(
12489    projection: &Projection,
12490    table: &str,
12491    alias: Option<&str>,
12492    out: &mut BTreeSet<String>,
12493) {
12494    match projection {
12495        Projection::Column(column) | Projection::Alias(column, _) => {
12496            match split_qualified_column(column) {
12497                Some((qualifier, column))
12498                    if qualifier == table || alias.is_some_and(|alias| qualifier == alias) =>
12499                {
12500                    push_policy_column(column, out);
12501                }
12502                Some(_) => {}
12503                None => push_policy_column(column, out),
12504            }
12505        }
12506        Projection::Field(
12507            FieldRef::TableColumn {
12508                table: qualifier,
12509                column,
12510            },
12511            _,
12512        ) => {
12513            if qualifier.is_empty()
12514                || qualifier == table
12515                || alias.is_some_and(|alias| qualifier == alias)
12516            {
12517                push_policy_column(column, out);
12518            }
12519        }
12520        Projection::Field(
12521            FieldRef::NodeProperty {
12522                alias: qualifier,
12523                property,
12524            },
12525            _,
12526        )
12527        | Projection::Field(
12528            FieldRef::EdgeProperty {
12529                alias: qualifier,
12530                property,
12531            },
12532            _,
12533        ) => {
12534            if qualifier == table || alias.is_some_and(|alias| qualifier == alias) {
12535                push_policy_column(property, out);
12536            }
12537        }
12538        Projection::Function(_, args) => {
12539            for arg in args {
12540                collect_projection_columns_for_table(arg, table, alias, out);
12541            }
12542        }
12543        Projection::Expression(_, _) | Projection::All | Projection::Field(_, _) => {}
12544        Projection::Window { args, .. } => {
12545            for arg in args {
12546                collect_projection_columns_for_table(arg, table, alias, out);
12547            }
12548        }
12549    }
12550}
12551
12552fn collect_projection_columns_for_join_side(
12553    projection: &Projection,
12554    left: Option<&JoinTableSide>,
12555    right: Option<&JoinTableSide>,
12556    out: &mut HashMap<String, BTreeSet<String>>,
12557) -> RedDBResult<()> {
12558    match projection {
12559        Projection::Column(column) | Projection::Alias(column, _) => {
12560            if let Some((qualifier, column)) = split_qualified_column(column) {
12561                push_qualified_join_column(qualifier, column, left, right, out);
12562            } else {
12563                push_unqualified_join_column(column, left, right, out);
12564            }
12565        }
12566        Projection::Field(FieldRef::TableColumn { table, column }, _) => {
12567            if table.is_empty() {
12568                push_unqualified_join_column(column, left, right, out);
12569            } else if let Some(side) = [left, right]
12570                .into_iter()
12571                .flatten()
12572                .find(|side| table == side.table.as_str() || table == side.alias.as_str())
12573            {
12574                push_join_column(&side.table, column, out);
12575            }
12576        }
12577        Projection::Field(FieldRef::NodeProperty { alias, property }, _)
12578        | Projection::Field(FieldRef::EdgeProperty { alias, property }, _) => {
12579            push_qualified_join_column(alias, property, left, right, out);
12580        }
12581        Projection::Function(_, args) => {
12582            for arg in args {
12583                collect_projection_columns_for_join_side(arg, left, right, out)?;
12584            }
12585        }
12586        Projection::Expression(_, _) | Projection::All | Projection::Field(_, _) => {}
12587        Projection::Window { args, .. } => {
12588            for arg in args {
12589                collect_projection_columns_for_join_side(arg, left, right, out)?;
12590            }
12591        }
12592    }
12593    Ok(())
12594}
12595
12596fn split_qualified_column(column: &str) -> Option<(&str, &str)> {
12597    let (qualifier, column) = column.split_once('.')?;
12598    if qualifier.is_empty() || column.is_empty() || column.contains('.') {
12599        return None;
12600    }
12601    Some((qualifier, column))
12602}
12603
12604fn push_qualified_join_column(
12605    qualifier: &str,
12606    column: &str,
12607    left: Option<&JoinTableSide>,
12608    right: Option<&JoinTableSide>,
12609    out: &mut HashMap<String, BTreeSet<String>>,
12610) {
12611    if let Some(side) = [left, right]
12612        .into_iter()
12613        .flatten()
12614        .find(|side| qualifier == side.table.as_str() || qualifier == side.alias.as_str())
12615    {
12616        push_join_column(&side.table, column, out);
12617    }
12618}
12619
12620fn push_unqualified_join_column(
12621    column: &str,
12622    left: Option<&JoinTableSide>,
12623    right: Option<&JoinTableSide>,
12624    out: &mut HashMap<String, BTreeSet<String>>,
12625) {
12626    for side in [left, right].into_iter().flatten() {
12627        push_join_column(&side.table, column, out);
12628    }
12629}
12630
12631fn push_join_column(table: &str, column: &str, out: &mut HashMap<String, BTreeSet<String>>) {
12632    if is_policy_column_name(column) {
12633        out.entry(table.to_string())
12634            .or_default()
12635            .insert(column.to_string());
12636    }
12637}
12638
12639fn push_policy_column(column: &str, out: &mut BTreeSet<String>) {
12640    if is_policy_column_name(column) {
12641        out.insert(column.to_string());
12642    }
12643}
12644
12645fn is_policy_column_name(column: &str) -> bool {
12646    !column.is_empty()
12647        && column != "*"
12648        && !column.starts_with("LIT:")
12649        && !column.starts_with("TYPE:")
12650}
12651
12652fn runtime_iam_context(
12653    role: crate::auth::Role,
12654    tenant: Option<&str>,
12655) -> crate::auth::policies::EvalContext {
12656    crate::auth::policies::EvalContext {
12657        principal_tenant: tenant.map(|t| t.to_string()),
12658        current_tenant: tenant.map(|t| t.to_string()),
12659        peer_ip: None,
12660        mfa_present: false,
12661        now_ms: crate::auth::now_ms(),
12662        principal_is_admin_role: role == crate::auth::Role::Admin,
12663        principal_is_platform_scoped: tenant.is_none(),
12664    }
12665}
12666
12667fn explicit_table_projection_columns(
12668    query: &crate::storage::query::ast::TableQuery,
12669) -> Vec<String> {
12670    use crate::storage::query::ast::{FieldRef, Projection};
12671
12672    let mut columns = Vec::new();
12673    for projection in crate::storage::query::sql_lowering::effective_table_projections(query) {
12674        match projection {
12675            Projection::Column(column) | Projection::Alias(column, _) => {
12676                push_unique(&mut columns, column)
12677            }
12678            Projection::Field(FieldRef::TableColumn { column, .. }, _) => {
12679                push_unique(&mut columns, column)
12680            }
12681            // SELECT * and expression/function projections need the
12682            // executor-wide column-policy context mapped in
12683            // docs/security/select-relational-column-policy-audit-2026-05-08.md.
12684            _ => {}
12685        }
12686    }
12687    columns
12688}
12689
12690fn explicit_graph_projection_properties(
12691    query: &crate::storage::query::ast::GraphQuery,
12692) -> Vec<String> {
12693    use crate::storage::query::ast::{FieldRef, Projection};
12694
12695    let mut columns = Vec::new();
12696    for projection in &query.return_ {
12697        match projection {
12698            Projection::Field(FieldRef::NodeProperty { property, .. }, _)
12699            | Projection::Field(FieldRef::EdgeProperty { property, .. }, _) => {
12700                push_unique(&mut columns, property.clone())
12701            }
12702            _ => {}
12703        }
12704    }
12705    columns
12706}
12707
12708fn push_unique(columns: &mut Vec<String>, column: String) {
12709    if !columns.iter().any(|existing| existing == &column) {
12710        columns.push(column);
12711    }
12712}
12713
12714fn principal_label(p: &crate::storage::query::ast::PolicyPrincipalRef) -> String {
12715    use crate::storage::query::ast::PolicyPrincipalRef;
12716    match p {
12717        PolicyPrincipalRef::User(u) => match &u.tenant {
12718            Some(t) => format!("user:{t}/{}", u.username),
12719            None => format!("user:{}", u.username),
12720        },
12721        PolicyPrincipalRef::Group(g) => format!("group:{g}"),
12722    }
12723}
12724
12725/// Render a `Decision` into the (decision, matched_policy_id, matched_sid)
12726/// shape used by every audit emit + the simulator response.
12727pub(crate) fn decision_to_strings(
12728    d: &crate::auth::policies::Decision,
12729) -> (String, Option<String>, Option<String>) {
12730    use crate::auth::policies::Decision;
12731    match d {
12732        Decision::Allow {
12733            matched_policy_id,
12734            matched_sid,
12735        } => (
12736            "allow".into(),
12737            Some(matched_policy_id.clone()),
12738            matched_sid.clone(),
12739        ),
12740        Decision::Deny {
12741            matched_policy_id,
12742            matched_sid,
12743        } => (
12744            "deny".into(),
12745            Some(matched_policy_id.clone()),
12746            matched_sid.clone(),
12747        ),
12748        Decision::DefaultDeny => ("default_deny".into(), None, None),
12749        Decision::AdminBypass => ("admin_bypass".into(), None, None),
12750    }
12751}
12752
12753fn relation_scopes_for_query(query: &QueryExpr) -> Vec<String> {
12754    let mut scopes = Vec::new();
12755    collect_relation_scopes(query, &mut scopes);
12756    scopes.sort();
12757    scopes.dedup();
12758    scopes
12759}
12760
12761fn collect_relation_scopes(query: &QueryExpr, scopes: &mut Vec<String>) {
12762    match query {
12763        QueryExpr::Table(table) => {
12764            if !table.table.is_empty() {
12765                scopes.push(table.table.clone());
12766            }
12767            if let Some(alias) = &table.alias {
12768                scopes.push(alias.clone());
12769            }
12770        }
12771        QueryExpr::Join(join) => {
12772            collect_relation_scopes(&join.left, scopes);
12773            collect_relation_scopes(&join.right, scopes);
12774        }
12775        _ => {}
12776    }
12777}
12778
12779fn query_references_outer_scope(query: &QueryExpr, outer_scopes: &[String]) -> bool {
12780    let inner_scopes = relation_scopes_for_query(query);
12781    query_expr_references_outer_scope(query, outer_scopes, &inner_scopes)
12782}
12783
12784fn query_expr_references_outer_scope(
12785    query: &QueryExpr,
12786    outer_scopes: &[String],
12787    inner_scopes: &[String],
12788) -> bool {
12789    match query {
12790        QueryExpr::Table(table) => {
12791            table.select_items.iter().any(|item| match item {
12792                crate::storage::query::ast::SelectItem::Wildcard => false,
12793                crate::storage::query::ast::SelectItem::Expr { expr, .. } => {
12794                    expr_references_outer_scope(expr, outer_scopes, inner_scopes)
12795                }
12796            }) || table
12797                .where_expr
12798                .as_ref()
12799                .is_some_and(|expr| expr_references_outer_scope(expr, outer_scopes, inner_scopes))
12800                || table.filter.as_ref().is_some_and(|filter| {
12801                    filter_references_outer_scope(filter, outer_scopes, inner_scopes)
12802                })
12803                || table.having_expr.as_ref().is_some_and(|expr| {
12804                    expr_references_outer_scope(expr, outer_scopes, inner_scopes)
12805                })
12806                || table.having.as_ref().is_some_and(|filter| {
12807                    filter_references_outer_scope(filter, outer_scopes, inner_scopes)
12808                })
12809                || table
12810                    .group_by_exprs
12811                    .iter()
12812                    .any(|expr| expr_references_outer_scope(expr, outer_scopes, inner_scopes))
12813                || table.order_by.iter().any(|clause| {
12814                    clause.expr.as_ref().is_some_and(|expr| {
12815                        expr_references_outer_scope(expr, outer_scopes, inner_scopes)
12816                    })
12817                })
12818        }
12819        QueryExpr::Join(join) => {
12820            query_expr_references_outer_scope(&join.left, outer_scopes, inner_scopes)
12821                || query_expr_references_outer_scope(&join.right, outer_scopes, inner_scopes)
12822                || join.filter.as_ref().is_some_and(|filter| {
12823                    filter_references_outer_scope(filter, outer_scopes, inner_scopes)
12824                })
12825                || join.return_items.iter().any(|item| match item {
12826                    crate::storage::query::ast::SelectItem::Wildcard => false,
12827                    crate::storage::query::ast::SelectItem::Expr { expr, .. } => {
12828                        expr_references_outer_scope(expr, outer_scopes, inner_scopes)
12829                    }
12830                })
12831        }
12832        _ => false,
12833    }
12834}
12835
12836fn filter_references_outer_scope(
12837    filter: &crate::storage::query::ast::Filter,
12838    outer_scopes: &[String],
12839    inner_scopes: &[String],
12840) -> bool {
12841    use crate::storage::query::ast::Filter;
12842    match filter {
12843        Filter::Compare { field, .. }
12844        | Filter::IsNull(field)
12845        | Filter::IsNotNull(field)
12846        | Filter::In { field, .. }
12847        | Filter::Between { field, .. }
12848        | Filter::Like { field, .. }
12849        | Filter::StartsWith { field, .. }
12850        | Filter::EndsWith { field, .. }
12851        | Filter::Contains { field, .. } => {
12852            field_ref_references_outer_scope(field, outer_scopes, inner_scopes)
12853        }
12854        Filter::CompareFields { left, right, .. } => {
12855            field_ref_references_outer_scope(left, outer_scopes, inner_scopes)
12856                || field_ref_references_outer_scope(right, outer_scopes, inner_scopes)
12857        }
12858        Filter::CompareExpr { lhs, rhs, .. } => {
12859            expr_references_outer_scope(lhs, outer_scopes, inner_scopes)
12860                || expr_references_outer_scope(rhs, outer_scopes, inner_scopes)
12861        }
12862        Filter::And(left, right) | Filter::Or(left, right) => {
12863            filter_references_outer_scope(left, outer_scopes, inner_scopes)
12864                || filter_references_outer_scope(right, outer_scopes, inner_scopes)
12865        }
12866        Filter::Not(inner) => filter_references_outer_scope(inner, outer_scopes, inner_scopes),
12867    }
12868}
12869
12870fn expr_references_outer_scope(
12871    expr: &crate::storage::query::ast::Expr,
12872    outer_scopes: &[String],
12873    inner_scopes: &[String],
12874) -> bool {
12875    use crate::storage::query::ast::Expr;
12876    match expr {
12877        Expr::Column { field, .. } => {
12878            field_ref_references_outer_scope(field, outer_scopes, inner_scopes)
12879        }
12880        Expr::BinaryOp { lhs, rhs, .. } => {
12881            expr_references_outer_scope(lhs, outer_scopes, inner_scopes)
12882                || expr_references_outer_scope(rhs, outer_scopes, inner_scopes)
12883        }
12884        Expr::UnaryOp { operand, .. }
12885        | Expr::Cast { inner: operand, .. }
12886        | Expr::IsNull { operand, .. } => {
12887            expr_references_outer_scope(operand, outer_scopes, inner_scopes)
12888        }
12889        Expr::FunctionCall { args, .. } => args
12890            .iter()
12891            .any(|arg| expr_references_outer_scope(arg, outer_scopes, inner_scopes)),
12892        Expr::Case {
12893            branches, else_, ..
12894        } => {
12895            branches.iter().any(|(cond, value)| {
12896                expr_references_outer_scope(cond, outer_scopes, inner_scopes)
12897                    || expr_references_outer_scope(value, outer_scopes, inner_scopes)
12898            }) || else_
12899                .as_ref()
12900                .is_some_and(|expr| expr_references_outer_scope(expr, outer_scopes, inner_scopes))
12901        }
12902        Expr::InList { target, values, .. } => {
12903            expr_references_outer_scope(target, outer_scopes, inner_scopes)
12904                || values
12905                    .iter()
12906                    .any(|value| expr_references_outer_scope(value, outer_scopes, inner_scopes))
12907        }
12908        Expr::Between {
12909            target, low, high, ..
12910        } => {
12911            expr_references_outer_scope(target, outer_scopes, inner_scopes)
12912                || expr_references_outer_scope(low, outer_scopes, inner_scopes)
12913                || expr_references_outer_scope(high, outer_scopes, inner_scopes)
12914        }
12915        Expr::Subquery { query, .. } => query_references_outer_scope(&query.query, inner_scopes),
12916        Expr::Literal { .. } | Expr::Parameter { .. } => false,
12917        Expr::WindowFunctionCall { args, window, .. } => {
12918            args.iter()
12919                .any(|arg| expr_references_outer_scope(arg, outer_scopes, inner_scopes))
12920                || window
12921                    .partition_by
12922                    .iter()
12923                    .any(|e| expr_references_outer_scope(e, outer_scopes, inner_scopes))
12924                || window
12925                    .order_by
12926                    .iter()
12927                    .any(|o| expr_references_outer_scope(&o.expr, outer_scopes, inner_scopes))
12928        }
12929    }
12930}
12931
12932fn field_ref_references_outer_scope(
12933    field: &crate::storage::query::ast::FieldRef,
12934    outer_scopes: &[String],
12935    inner_scopes: &[String],
12936) -> bool {
12937    match field {
12938        crate::storage::query::ast::FieldRef::TableColumn { table, .. } if !table.is_empty() => {
12939            outer_scopes.iter().any(|scope| scope == table)
12940                && !inner_scopes.iter().any(|scope| scope == table)
12941        }
12942        _ => false,
12943    }
12944}
12945
12946fn first_column_values(
12947    result: crate::storage::query::unified::UnifiedResult,
12948) -> RedDBResult<Vec<Value>> {
12949    if result.columns.len() > 1 {
12950        return Err(RedDBError::Query(
12951            "expression subquery must return exactly one column".to_string(),
12952        ));
12953    }
12954    let fallback_column = result
12955        .records
12956        .first()
12957        .and_then(|record| record.column_names().into_iter().next())
12958        .map(|name| name.to_string());
12959    let column = result.columns.first().cloned().or(fallback_column);
12960    let Some(column) = column else {
12961        return Ok(Vec::new());
12962    };
12963    Ok(result
12964        .records
12965        .iter()
12966        .map(|record| record.get(column.as_str()).cloned().unwrap_or(Value::Null))
12967        .collect())
12968}
12969
12970fn parse_timestamp_to_ms(s: &str) -> Option<u128> {
12971    // Bare integer ms.
12972    if let Ok(n) = s.parse::<u128>() {
12973        return Some(n);
12974    }
12975    // Fallback: ISO-8601 like 2030-01-02 03:04:05 — accept the date
12976    // portion only (midnight UTC). Full RFC3339 parsing is a stretch
12977    // goal; the common case is `'2030-01-01'`.
12978    if let Some(date) = s.split_whitespace().next() {
12979        let parts: Vec<&str> = date.split('-').collect();
12980        if parts.len() == 3 {
12981            let (y, m, d) = (parts[0], parts[1], parts[2]);
12982            if let (Ok(y), Ok(m), Ok(d)) = (y.parse::<i64>(), m.parse::<u32>(), d.parse::<u32>()) {
12983                // Days since 1970-01-01 — simple Julian arithmetic
12984                // suitable for years 1970-2100. Good enough for test
12985                // fixtures; precise parsing lands when we wire chrono.
12986                let days_in = days_from_civil(y, m, d);
12987                return Some((days_in as u128) * 86_400_000u128);
12988            }
12989        }
12990    }
12991    None
12992}
12993
12994/// Days from Unix epoch using H. Hinnant's civil-from-days algorithm.
12995/// Robust for the entire Gregorian range; used by `parse_timestamp_to_ms`.
12996fn days_from_civil(y: i64, m: u32, d: u32) -> i64 {
12997    let y = if m <= 2 { y - 1 } else { y };
12998    let era = if y >= 0 { y } else { y - 399 } / 400;
12999    let yoe = (y - era * 400) as u64; // [0, 399]
13000    let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) as u64 + 2) / 5 + d as u64 - 1;
13001    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
13002    era * 146097 + doe as i64 - 719468
13003}
13004
13005fn walk_plan_node(
13006    node: &crate::storage::query::planner::CanonicalLogicalNode,
13007    depth: usize,
13008    out: &mut Vec<crate::storage::query::unified::UnifiedRecord>,
13009) {
13010    use std::sync::Arc;
13011    let mut rec = crate::storage::query::unified::UnifiedRecord::default();
13012    rec.set_arc(Arc::from("op"), Value::text(node.operator.clone()));
13013    rec.set_arc(
13014        Arc::from("source"),
13015        node.source.clone().map(Value::text).unwrap_or(Value::Null),
13016    );
13017    rec.set_arc(Arc::from("est_rows"), Value::Float(node.estimated_rows));
13018    rec.set_arc(Arc::from("est_cost"), Value::Float(node.operator_cost));
13019    rec.set_arc(Arc::from("depth"), Value::Integer(depth as i64));
13020    out.push(rec);
13021    for child in &node.children {
13022        walk_plan_node(child, depth + 1, out);
13023    }
13024}
13025
13026#[cfg(test)]
13027mod inline_graph_tvf_tests {
13028    use super::*;
13029
13030    fn scopes_for(sql: &str) -> HashSet<String> {
13031        let expr = crate::storage::query::parser::parse(sql)
13032            .expect("parse")
13033            .query;
13034        query_expr_result_cache_scopes(&expr)
13035    }
13036
13037    #[test]
13038    fn inline_tvf_cache_scopes_include_source_collections() {
13039        // The result-cache key for the inline form must derive from the
13040        // `nodes`/`edges` source collections so a write to either invalidates
13041        // the cached result (issue #799).
13042        let scopes = scopes_for(
13043            "SELECT * FROM components(nodes => (SELECT id FROM hosts), edges => (SELECT src, dst FROM links))",
13044        );
13045        assert!(scopes.contains("hosts"), "nodes source scoped: {scopes:?}");
13046        assert!(scopes.contains("links"), "edges source scoped: {scopes:?}");
13047    }
13048
13049    #[test]
13050    fn graph_collection_tvf_cache_scope_is_graph_argument() {
13051        // The graph-collection form still materializes the active graph, but
13052        // result-cache invalidation is scoped to the named graph argument so
13053        // INSERT INTO g NODE/EDGE invalidates cached TVF rows.
13054        let scopes = scopes_for("SELECT * FROM components(g)");
13055        assert!(scopes.contains("g"), "collection form scoped: {scopes:?}");
13056    }
13057
13058    #[test]
13059    fn abstract_degree_centrality_counts_undirected_endpoints() {
13060        let nodes = vec!["a".to_string(), "b".to_string(), "c".to_string()];
13061        let edges = vec![
13062            ("a".to_string(), "b".to_string(), 1.0_f32),
13063            ("b".to_string(), "c".to_string(), 1.0_f32),
13064        ];
13065        let degrees = abstract_degree_centrality(&nodes, &edges);
13066        assert_eq!(
13067            degrees,
13068            vec![
13069                ("a".to_string(), 1),
13070                ("b".to_string(), 2),
13071                ("c".to_string(), 1),
13072            ]
13073        );
13074    }
13075}