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::CreateVcsRef(q) => {
709            let kind = match q.kind {
710                crate::storage::query::ast::VcsRefKind::Branch => "branch",
711                crate::storage::query::ast::VcsRefKind::Tag => "tag",
712            };
713            schema("create_vcs_ref", Some(format!("{kind}:{}", q.name)));
714        }
715        QueryExpr::DropVcsRef(q) => {
716            let kind = match q.kind {
717                crate::storage::query::ast::VcsRefKind::Branch => "branch",
718                crate::storage::query::ast::VcsRefKind::Tag => "tag",
719            };
720            schema("drop_vcs_ref", Some(format!("{kind}:{}", q.name)));
721        }
722        QueryExpr::CreateIndex(q) => {
723            schema(
724                "create_index",
725                Some(format!("index:{}:{}", q.table, q.name)),
726            );
727        }
728        QueryExpr::DropIndex(q) => {
729            schema("drop_index", Some(format!("index:{}:{}", q.table, q.name)));
730        }
731        QueryExpr::CreateTimeSeries(q) => {
732            schema("create_timeseries", Some(format!("timeseries:{}", q.name)));
733        }
734        QueryExpr::CreateMetric(q) => {
735            schema("create_metric", Some(format!("metric:{}", q.path)));
736        }
737        QueryExpr::AlterMetric(q) => {
738            schema("alter_metric", Some(format!("metric:{}", q.path)));
739        }
740        QueryExpr::CreateSlo(q) => {
741            schema("create_slo", Some(format!("slo:{}", q.path)));
742        }
743        QueryExpr::DropTimeSeries(q) => {
744            schema("drop_timeseries", Some(format!("timeseries:{}", q.name)));
745        }
746        QueryExpr::CreateQueue(q) => schema("create_queue", Some(format!("queue:{}", q.name))),
747        QueryExpr::AlterQueue(q) => schema("alter_queue", Some(format!("queue:{}", q.name))),
748        QueryExpr::DropQueue(q) => schema("drop_queue", Some(format!("queue:{}", q.name))),
749        QueryExpr::CreateTree(q) => {
750            schema(
751                "create_tree",
752                Some(format!("tree:{}:{}", q.collection, q.name)),
753            );
754        }
755        QueryExpr::DropTree(q) => {
756            schema(
757                "drop_tree",
758                Some(format!("tree:{}:{}", q.collection, q.name)),
759            );
760        }
761        QueryExpr::CreateSchema(q) => schema("create_schema", Some(format!("schema:{}", q.name))),
762        QueryExpr::DropSchema(q) => schema("drop_schema", Some(format!("schema:{}", q.name))),
763        QueryExpr::CreateSequence(q) => {
764            schema("create_sequence", Some(format!("sequence:{}", q.name)));
765        }
766        QueryExpr::DropSequence(q) => schema("drop_sequence", Some(format!("sequence:{}", q.name))),
767        QueryExpr::CreateView(q) => schema("create_view", Some(format!("view:{}", q.name))),
768        QueryExpr::DropView(q) => schema("drop_view", Some(format!("view:{}", q.name))),
769        QueryExpr::RefreshMaterializedView(q) => {
770            schema(
771                "refresh_materialized_view",
772                Some(format!("view:{}", q.name)),
773            );
774        }
775        QueryExpr::CreatePolicy(q) => {
776            specs.push(QueryControlEventSpec {
777                kind: EventKind::RlsGovernance,
778                action: "create_policy",
779                resource: Some(format!("table:{}:policy:{}", q.table, q.name)),
780                fields: vec![(
781                    "target_kind".to_string(),
782                    Sensitivity::raw(q.target_kind.as_ident()),
783                )],
784            });
785        }
786        QueryExpr::DropPolicy(q) => {
787            specs.push(QueryControlEventSpec {
788                kind: EventKind::RlsGovernance,
789                action: "drop_policy",
790                resource: Some(format!("table:{}:policy:{}", q.table, q.name)),
791                fields: Vec::new(),
792            });
793        }
794        QueryExpr::SetTenant(value) => {
795            let mut fields = Vec::new();
796            if let Some(value) = value {
797                fields.push(("tenant".to_string(), Sensitivity::raw(value)));
798            }
799            specs.push(QueryControlEventSpec {
800                kind: EventKind::TenantGovernance,
801                action: "set_tenant",
802                resource: Some("tenant:session".to_string()),
803                fields,
804            });
805        }
806        QueryExpr::SetConfig { key, .. } => {
807            specs.push(QueryControlEventSpec {
808                kind: EventKind::ConfigWrite,
809                action: "config:write",
810                resource: Some(format!("config:{key}")),
811                fields: vec![("key".to_string(), Sensitivity::raw(key))],
812            });
813        }
814        QueryExpr::ConfigCommand(cmd) => match cmd {
815            crate::storage::query::ast::ConfigCommand::Put {
816                collection, key, ..
817            }
818            | crate::storage::query::ast::ConfigCommand::Rotate {
819                collection, key, ..
820            } => {
821                let target = format!("{collection}/{key}");
822                specs.push(QueryControlEventSpec {
823                    kind: EventKind::ConfigWrite,
824                    action: "config:write",
825                    resource: Some(format!("config:{target}")),
826                    fields: vec![
827                        ("collection".to_string(), Sensitivity::raw(collection)),
828                        ("key".to_string(), Sensitivity::raw(key)),
829                    ],
830                });
831            }
832            crate::storage::query::ast::ConfigCommand::Delete { collection, key } => {
833                let target = format!("{collection}/{key}");
834                specs.push(QueryControlEventSpec {
835                    kind: EventKind::ConfigDelete,
836                    action: "config:write",
837                    resource: Some(format!("config:{target}")),
838                    fields: vec![
839                        ("collection".to_string(), Sensitivity::raw(collection)),
840                        ("key".to_string(), Sensitivity::raw(key)),
841                    ],
842                });
843            }
844            _ => {}
845        },
846        QueryExpr::AlterUser(stmt) => {
847            let disables = stmt.attributes.iter().any(|attr| {
848                matches!(
849                    attr,
850                    crate::storage::query::ast::AlterUserAttribute::Disable
851                )
852            });
853            specs.push(QueryControlEventSpec {
854                kind: if disables {
855                    EventKind::UserDisable
856                } else {
857                    EventKind::UserUpdate
858                },
859                action: "alter_user",
860                resource: Some(format!("user:{}", stmt.username)),
861                fields: Vec::new(),
862            });
863        }
864        QueryExpr::CreateUser(stmt) => {
865            specs.push(QueryControlEventSpec {
866                kind: EventKind::UserCreate,
867                action: "create_user",
868                resource: Some(format!("user:{}", stmt.username)),
869                fields: Vec::new(),
870            });
871        }
872        _ => {}
873    }
874    specs
875}
876
877pub(crate) fn control_event_outcome_for_error(
878    err: &RedDBError,
879) -> crate::runtime::control_events::Outcome {
880    match err {
881        RedDBError::ReadOnly(_) => crate::runtime::control_events::Outcome::Denied,
882        RedDBError::Query(msg)
883            if msg.contains("permission denied")
884                || msg.contains("cannot issue")
885                || msg.contains("lacks") =>
886        {
887            crate::runtime::control_events::Outcome::Denied
888        }
889        _ => crate::runtime::control_events::Outcome::Error,
890    }
891}
892
893/// Convert the rows produced by a materialized-view body into
894/// `UnifiedEntity` table rows targeting the backing collection.
895/// Issue #595 slice 9c — feeds `UnifiedStore::refresh_collection`.
896///
897/// Graph fragments and vector hits are ignored: a materialized view
898/// is a relational result set (SELECT-shaped); slices 11+ may extend
899/// this once we have a richer view body shape. Each row materialises
900/// the union of its schema-bound columns + overflow.
901fn view_records_to_entities(
902    table: &str,
903    records: &[crate::storage::query::unified::UnifiedRecord],
904) -> Vec<crate::storage::UnifiedEntity> {
905    use std::collections::HashMap;
906    let table_arc: std::sync::Arc<str> = std::sync::Arc::from(table);
907    let mut out = Vec::with_capacity(records.len());
908    for record in records {
909        let mut named: HashMap<String, crate::storage::schema::Value> = HashMap::new();
910        for (name, value) in record.iter_fields() {
911            named.insert(name.to_string(), value.clone());
912        }
913        let entity = crate::storage::UnifiedEntity::new(
914            crate::storage::EntityId::new(0),
915            crate::storage::EntityKind::TableRow {
916                table: std::sync::Arc::clone(&table_arc),
917                row_id: 0,
918            },
919            crate::storage::EntityData::Row(crate::storage::RowData {
920                columns: Vec::new(),
921                named: Some(named),
922                schema: None,
923            }),
924        );
925        out.push(entity);
926    }
927    out
928}
929
930fn system_keyed_collection_contract(
931    name: &str,
932    model: crate::catalog::CollectionModel,
933) -> crate::physical::CollectionContract {
934    let now = crate::utils::now_unix_millis() as u128;
935    crate::physical::CollectionContract {
936        name: name.to_string(),
937        declared_model: model,
938        schema_mode: crate::catalog::SchemaMode::Dynamic,
939        origin: crate::physical::ContractOrigin::Implicit,
940        version: 1,
941        created_at_unix_ms: now,
942        updated_at_unix_ms: now,
943        default_ttl_ms: None,
944        vector_dimension: None,
945        vector_metric: None,
946        context_index_fields: Vec::new(),
947        declared_columns: Vec::new(),
948        table_def: None,
949        timestamps_enabled: false,
950        context_index_enabled: false,
951        metrics_raw_retention_ms: None,
952        metrics_rollup_policies: Vec::new(),
953        metrics_tenant_identity: None,
954        metrics_namespace: None,
955        append_only: false,
956        subscriptions: Vec::new(),
957        analytics_config: Vec::new(),
958        session_key: None,
959        session_gap_ms: None,
960        retention_duration_ms: None,
961        analytical_storage: None,
962
963        ai_policy: None,
964    }
965}
966
967pub use super::execution_context::{
968    capture_current_snapshot, clear_current_auth_identity, clear_current_connection_id,
969    clear_current_snapshot, clear_current_tenant, current_auth_identity_for_audit,
970    current_connection_id, current_tenant, entity_visible_under_current_snapshot,
971    entity_visible_with_context, set_current_auth_identity, set_current_connection_id,
972    set_current_snapshot, set_current_tenant, snapshot_bundle, with_snapshot_bundle,
973    SnapshotBundle, SnapshotContext,
974};
975pub(crate) use super::execution_context::{
976    current_auth_identity, current_config_value, current_role_projected, current_scope_override,
977    current_secret_value, current_snapshot_requires_index_fallback, current_user_projected,
978    has_scope_override_active, parse_set_local_tenant, update_current_config_value,
979    update_current_secret_value, xids_visible_under_current_snapshot, ConfigSnapshotGuard,
980    CurrentSnapshotGuard, ScopeOverrideGuard, SecretStoreGuard, TxLocalTenantGuard,
981};
982
983fn table_row_index_fields(
984    entity: &crate::storage::unified::entity::UnifiedEntity,
985) -> Vec<(String, crate::storage::schema::Value)> {
986    let crate::storage::EntityData::Row(row) = &entity.data else {
987        return Vec::new();
988    };
989    if let Some(named) = &row.named {
990        return named
991            .iter()
992            .map(|(name, value)| (name.clone(), value.clone()))
993            .collect();
994    }
995    if let Some(schema) = &row.schema {
996        return schema
997            .iter()
998            .zip(row.columns.iter())
999            .map(|(name, value)| (name.clone(), value.clone()))
1000            .collect();
1001    }
1002    Vec::new()
1003}
1004
1005fn named_text(
1006    named: &std::collections::HashMap<String, crate::storage::schema::Value>,
1007    key: &str,
1008) -> Option<String> {
1009    match named.get(key) {
1010        Some(crate::storage::schema::Value::Text(value)) => Some(value.to_string()),
1011        _ => None,
1012    }
1013}
1014
1015fn named_bool(
1016    named: &std::collections::HashMap<String, crate::storage::schema::Value>,
1017    key: &str,
1018) -> Option<bool> {
1019    match named.get(key) {
1020        Some(crate::storage::schema::Value::Boolean(value)) => Some(*value),
1021        _ => None,
1022    }
1023}
1024
1025fn named_i64(
1026    named: &std::collections::HashMap<String, crate::storage::schema::Value>,
1027    key: &str,
1028) -> Option<i64> {
1029    match named.get(key) {
1030        Some(crate::storage::schema::Value::Integer(value)) => Some(*value),
1031        _ => None,
1032    }
1033}
1034
1035fn index_method_kind_as_str(method: super::index_store::IndexMethodKind) -> &'static str {
1036    match method {
1037        super::index_store::IndexMethodKind::Hash => "hash",
1038        super::index_store::IndexMethodKind::Bitmap => "bitmap",
1039        super::index_store::IndexMethodKind::Spatial => "spatial",
1040        super::index_store::IndexMethodKind::BTree => "btree",
1041        // The H3 resolution rides in a sibling `resolution` column of the
1042        // persisted descriptor; the method tag itself is just "h3".
1043        super::index_store::IndexMethodKind::H3 { .. } => "h3",
1044    }
1045}
1046
1047/// The H3 resolution to persist for an index. Non-H3 kinds persist `0`,
1048/// which the rehydrate path ignores.
1049fn index_method_kind_resolution(method: super::index_store::IndexMethodKind) -> u8 {
1050    match method {
1051        super::index_store::IndexMethodKind::H3 { resolution } => resolution,
1052        _ => 0,
1053    }
1054}
1055
1056fn index_method_kind_from_str(
1057    raw: &str,
1058    resolution: u8,
1059) -> Option<super::index_store::IndexMethodKind> {
1060    match raw {
1061        "hash" => Some(super::index_store::IndexMethodKind::Hash),
1062        "bitmap" => Some(super::index_store::IndexMethodKind::Bitmap),
1063        "spatial" | "rtree" => Some(super::index_store::IndexMethodKind::Spatial),
1064        "btree" => Some(super::index_store::IndexMethodKind::BTree),
1065        "h3" => Some(super::index_store::IndexMethodKind::H3 { resolution }),
1066        _ => None,
1067    }
1068}
1069
1070fn runtime_pool_lock(runtime: &RedDBRuntime) -> std::sync::MutexGuard<'_, PoolState> {
1071    runtime
1072        .inner
1073        .pool
1074        .lock()
1075        .unwrap_or_else(|poisoned| poisoned.into_inner())
1076}
1077
1078fn json_runtime_value(value: crate::json::Value) -> Value {
1079    crate::json::to_vec(&value)
1080        .map(Value::Json)
1081        .unwrap_or(Value::Null)
1082}
1083
1084/// The graph-analytics table-valued functions recognized in FROM position.
1085/// Both the graph-collection form and the inline `nodes => / edges =>` form
1086/// (issue #799) accept these names.
1087fn is_graph_tvf_name(name: &str) -> bool {
1088    name.eq_ignore_ascii_case("components")
1089        || name.eq_ignore_ascii_case("louvain")
1090        || name.eq_ignore_ascii_case("degree_centrality")
1091        || name.eq_ignore_ascii_case("shortest_path")
1092        || name.eq_ignore_ascii_case("betweenness")
1093        || name.eq_ignore_ascii_case("eigenvector")
1094        || name.eq_ignore_ascii_case("pagerank")
1095}
1096
1097/// Map a declared `WITH ANALYTICS` view to the concrete graph algorithm name
1098/// and named-argument list that [`RedDBRuntime::dispatch_graph_algorithm`]
1099/// consumes (issue #800). The `using` option selects the algorithm inside the
1100/// output family; unsupported algorithms and the options that do not apply to
1101/// the chosen algorithm are rejected so a view never silently ignores a
1102/// declared parameter.
1103fn analytics_view_algorithm(
1104    graph: &str,
1105    view: &crate::catalog::AnalyticsViewDescriptor,
1106) -> RedDBResult<(String, Vec<(String, f64)>)> {
1107    use crate::catalog::AnalyticsOutput;
1108
1109    let mut named_args: Vec<(String, f64)> = Vec::new();
1110    let algorithm = match view.output {
1111        AnalyticsOutput::Communities => {
1112            let algo = view.algorithm.as_deref().unwrap_or("louvain");
1113            if !algo.eq_ignore_ascii_case("louvain") {
1114                return Err(RedDBError::Query(format!(
1115                    "analytics output 'communities' on graph '{graph}' has unsupported algorithm '{algo}' (expected louvain)"
1116                )));
1117            }
1118            if let Some(resolution) = view.resolution {
1119                named_args.push(("resolution".to_string(), resolution));
1120            }
1121            "louvain".to_string()
1122        }
1123        AnalyticsOutput::Components => {
1124            if let Some(algo) = view.algorithm.as_deref() {
1125                if !algo.eq_ignore_ascii_case("components")
1126                    && !algo.eq_ignore_ascii_case("connected_components")
1127                {
1128                    return Err(RedDBError::Query(format!(
1129                        "analytics output 'components' on graph '{graph}' has unsupported algorithm '{algo}' (expected connected_components)"
1130                    )));
1131                }
1132            }
1133            "components".to_string()
1134        }
1135        AnalyticsOutput::Centrality => {
1136            let algo = view
1137                .algorithm
1138                .as_deref()
1139                .unwrap_or("pagerank")
1140                .to_ascii_lowercase();
1141            match algo.as_str() {
1142                "pagerank" => {
1143                    if let Some(max_iterations) = view.max_iterations {
1144                        named_args.push(("max_iterations".to_string(), max_iterations as f64));
1145                    }
1146                }
1147                "eigenvector" => {
1148                    if let Some(max_iterations) = view.max_iterations {
1149                        named_args.push(("max_iterations".to_string(), max_iterations as f64));
1150                    }
1151                    if let Some(tolerance) = view.tolerance {
1152                        named_args.push(("tolerance".to_string(), tolerance));
1153                    }
1154                }
1155                "betweenness" => {}
1156                other => {
1157                    return Err(RedDBError::Query(format!(
1158                        "analytics output 'centrality' on graph '{graph}' has unsupported algorithm '{other}' (expected pagerank, betweenness, or eigenvector)"
1159                    )));
1160                }
1161            }
1162            algo
1163        }
1164    };
1165    Ok((algorithm, named_args))
1166}
1167
1168/// Reject any named arguments for a TVF that accepts none.
1169fn reject_named_args(name: &str, named_args: &[(String, f64)]) -> RedDBResult<()> {
1170    if let Some((key, _)) = named_args.first() {
1171        return Err(RedDBError::Query(format!(
1172            "table function '{name}' has no named argument '{key}'"
1173        )));
1174    }
1175    Ok(())
1176}
1177
1178/// Resolve louvain's optional `resolution` named arg (γ, default 1.0). Any
1179/// other named key, or a non-finite / non-positive resolution, is rejected.
1180fn louvain_resolution(named_args: &[(String, f64)]) -> RedDBResult<f64> {
1181    let mut resolution = 1.0_f64;
1182    for (key, value) in named_args {
1183        if key.eq_ignore_ascii_case("resolution") {
1184            if !value.is_finite() || *value <= 0.0 {
1185                return Err(RedDBError::Query(format!(
1186                    "table function 'louvain' resolution must be > 0, got {value}"
1187                )));
1188            }
1189            resolution = *value;
1190        } else {
1191            return Err(RedDBError::Query(format!(
1192                "table function 'louvain' has no named argument '{key}' (expected 'resolution')"
1193            )));
1194        }
1195    }
1196    Ok(resolution)
1197}
1198
1199/// Undirected degree centrality over abstract inputs: each edge contributes
1200/// 1 to both of its endpoints. Returns `(node_id, degree)` deterministically
1201/// in ascending node-id order, so identical input always yields identical
1202/// rows.
1203fn abstract_degree_centrality(
1204    nodes: &[String],
1205    edges: &[(
1206        String,
1207        String,
1208        crate::storage::engine::graph_algorithms::Weight,
1209    )],
1210) -> Vec<(String, usize)> {
1211    let mut degree: std::collections::BTreeMap<String, usize> = std::collections::BTreeMap::new();
1212    for n in nodes {
1213        degree.entry(n.clone()).or_insert(0);
1214    }
1215    for (a, b, _w) in edges {
1216        *degree.entry(a.clone()).or_insert(0) += 1;
1217        *degree.entry(b.clone()).or_insert(0) += 1;
1218    }
1219    degree.into_iter().collect()
1220}
1221
1222/// Ordered column names for a materialized subquery result: the projection
1223/// columns when present, else the first record's field order.
1224fn ordered_result_columns(result: &crate::storage::query::unified::UnifiedResult) -> Vec<String> {
1225    if !result.columns.is_empty() {
1226        return result.columns.clone();
1227    }
1228    result
1229        .records
1230        .first()
1231        .map(|record| {
1232            record
1233                .column_names()
1234                .iter()
1235                .map(|column| column.to_string())
1236                .collect()
1237        })
1238        .unwrap_or_default()
1239}
1240
1241/// Canonical node-id string for a cell value, so the node universe (from the
1242/// `nodes` subquery) and the edge endpoints (from the `edges` subquery)
1243/// compare equal regardless of integer-vs-text typing. `Null` is not a node.
1244fn value_to_node_id(value: &crate::storage::schema::Value) -> Option<String> {
1245    use crate::storage::schema::Value;
1246    match value {
1247        Value::Null => None,
1248        Value::Text(s) => Some(s.to_string()),
1249        Value::Integer(n) => Some(n.to_string()),
1250        Value::UnsignedInteger(n) => Some(n.to_string()),
1251        Value::NodeRef(s) => Some(s.clone()),
1252        other => Some(other.to_string()),
1253    }
1254}
1255
1256/// Numeric edge weight from a cell value (the optional third `edges` column).
1257fn value_to_weight(value: &crate::storage::schema::Value) -> Option<f32> {
1258    use crate::storage::schema::Value;
1259    match value {
1260        Value::Float(f) => Some(*f as f32),
1261        Value::Integer(n) => Some(*n as f32),
1262        Value::UnsignedInteger(n) => Some(*n as f32),
1263        _ => None,
1264    }
1265}
1266
1267/// Build the node universe from a materialized `nodes` subquery result: the
1268/// first projected column of each row is the node id (issue #799). Zero rows
1269/// is a valid empty node set; a row set with no columns is a shape error.
1270fn inline_node_ids(
1271    name: &str,
1272    result: &crate::storage::query::unified::UnifiedResult,
1273) -> RedDBResult<Vec<String>> {
1274    if result.records.is_empty() {
1275        return Ok(Vec::new());
1276    }
1277    let columns = ordered_result_columns(result);
1278    let Some(first_col) = columns.first() else {
1279        return Err(RedDBError::Query(format!(
1280            "table function '{name}' inline form: `nodes` subquery must project at least one column (the node id)"
1281        )));
1282    };
1283    let mut ids = Vec::with_capacity(result.records.len());
1284    for record in &result.records {
1285        if let Some(id) = record.get(first_col).and_then(value_to_node_id) {
1286            ids.push(id);
1287        }
1288    }
1289    Ok(ids)
1290}
1291
1292/// Build the edge list from a materialized `edges` subquery result: the first
1293/// two projected columns are `(source, target)` and an optional third column
1294/// is the numeric weight (defaulting to 1.0). Fewer than two columns is a
1295/// shape error (issue #799).
1296fn inline_edges(
1297    name: &str,
1298    result: &crate::storage::query::unified::UnifiedResult,
1299) -> RedDBResult<
1300    Vec<(
1301        String,
1302        String,
1303        crate::storage::engine::graph_algorithms::Weight,
1304    )>,
1305> {
1306    if result.records.is_empty() {
1307        return Ok(Vec::new());
1308    }
1309    let columns = ordered_result_columns(result);
1310    if columns.len() < 2 {
1311        return Err(RedDBError::Query(format!(
1312            "table function '{name}' inline form: `edges` subquery must project at least two columns (source, target), got {}",
1313            columns.len()
1314        )));
1315    }
1316    let src_col = &columns[0];
1317    let dst_col = &columns[1];
1318    let weight_col = columns.get(2);
1319    let mut edges = Vec::with_capacity(result.records.len());
1320    for record in &result.records {
1321        let (Some(src), Some(dst)) = (
1322            record.get(src_col).and_then(value_to_node_id),
1323            record.get(dst_col).and_then(value_to_node_id),
1324        ) else {
1325            // A null/absent endpoint is not a valid edge; skip it.
1326            continue;
1327        };
1328        let weight = match weight_col {
1329            Some(col) => match record.get(col) {
1330                None | Some(crate::storage::schema::Value::Null) => 1.0,
1331                Some(value) => value_to_weight(value).ok_or_else(|| {
1332                    RedDBError::Query(format!(
1333                        "table function '{name}' inline form: `edges` weight column must be numeric"
1334                    ))
1335                })?,
1336            },
1337            None => 1.0,
1338        };
1339        edges.push((src, dst, weight));
1340    }
1341    Ok(edges)
1342}
1343
1344fn cache_scope_insert(scopes: &mut HashSet<String>, name: &str) {
1345    if name.is_empty() || name.starts_with("__subq_") || is_universal_query_source(name) {
1346        return;
1347    }
1348    scopes.insert(name.to_string());
1349}
1350
1351fn collect_table_source_scopes(scopes: &mut HashSet<String>, query: &TableQuery) {
1352    match query.source.as_ref() {
1353        Some(crate::storage::query::ast::TableSource::Name(name)) => {
1354            cache_scope_insert(scopes, name)
1355        }
1356        Some(crate::storage::query::ast::TableSource::Subquery(subquery)) => {
1357            collect_query_expr_result_cache_scopes(scopes, subquery);
1358        }
1359        // Graph-collection TVFs (e.g. `louvain(g)`) read the graph store
1360        // read-only. The result is now cached (issue #802) and scoped to the
1361        // graph collection named in the first argument, so any mutation on
1362        // that collection (`INSERT INTO g NODE/EDGE …`) invalidates the
1363        // entry via `invalidate_result_cache_for_table`. Non-graph or
1364        // zero-arg functions contribute no scope.
1365        Some(crate::storage::query::ast::TableSource::Function { name, args, .. }) => {
1366            if is_graph_tvf_name(name) {
1367                if let Some(graph) = args.first() {
1368                    cache_scope_insert(scopes, graph);
1369                }
1370            }
1371        }
1372        // The inline-graph form reads ordinary tables/docs through its
1373        // `nodes`/`edges` subqueries, so its result cache must be scoped to
1374        // those source collections — mutating any of them invalidates the
1375        // cached result (issue #799).
1376        Some(crate::storage::query::ast::TableSource::InlineGraphFunction {
1377            nodes, edges, ..
1378        }) => {
1379            collect_query_expr_result_cache_scopes(scopes, nodes);
1380            collect_query_expr_result_cache_scopes(scopes, edges);
1381        }
1382        None => cache_scope_insert(scopes, &query.table),
1383    }
1384}
1385
1386fn collect_vector_source_scopes(
1387    scopes: &mut HashSet<String>,
1388    source: &crate::storage::query::ast::VectorSource,
1389) {
1390    match source {
1391        crate::storage::query::ast::VectorSource::Reference { collection, .. } => {
1392            cache_scope_insert(scopes, collection);
1393        }
1394        crate::storage::query::ast::VectorSource::Subquery(subquery) => {
1395            collect_query_expr_result_cache_scopes(scopes, subquery);
1396        }
1397        crate::storage::query::ast::VectorSource::Literal(_)
1398        | crate::storage::query::ast::VectorSource::Text(_) => {}
1399    }
1400}
1401
1402fn collect_path_selector_scopes(
1403    scopes: &mut HashSet<String>,
1404    selector: &crate::storage::query::ast::NodeSelector,
1405) {
1406    if let crate::storage::query::ast::NodeSelector::ByRow { table, .. } = selector {
1407        cache_scope_insert(scopes, table);
1408    }
1409}
1410
1411fn collect_query_expr_result_cache_scopes(scopes: &mut HashSet<String>, expr: &QueryExpr) {
1412    match expr {
1413        QueryExpr::Table(query) => collect_table_source_scopes(scopes, query),
1414        QueryExpr::Join(query) => {
1415            collect_query_expr_result_cache_scopes(scopes, &query.left);
1416            collect_query_expr_result_cache_scopes(scopes, &query.right);
1417        }
1418        QueryExpr::Path(query) => {
1419            collect_path_selector_scopes(scopes, &query.from);
1420            collect_path_selector_scopes(scopes, &query.to);
1421        }
1422        QueryExpr::Vector(query) => {
1423            cache_scope_insert(scopes, &query.collection);
1424            collect_vector_source_scopes(scopes, &query.query_vector);
1425        }
1426        QueryExpr::Hybrid(query) => {
1427            collect_query_expr_result_cache_scopes(scopes, &query.structured);
1428            cache_scope_insert(scopes, &query.vector.collection);
1429            collect_vector_source_scopes(scopes, &query.vector.query_vector);
1430        }
1431        QueryExpr::Insert(query) => cache_scope_insert(scopes, &query.table),
1432        QueryExpr::Update(query) => cache_scope_insert(scopes, &query.table),
1433        QueryExpr::Delete(query) => cache_scope_insert(scopes, &query.table),
1434        QueryExpr::CreateTable(query) => cache_scope_insert(scopes, &query.name),
1435        QueryExpr::CreateCollection(query) => cache_scope_insert(scopes, &query.name),
1436        QueryExpr::CreateVector(query) => cache_scope_insert(scopes, &query.name),
1437        QueryExpr::DropTable(query) => cache_scope_insert(scopes, &query.name),
1438        QueryExpr::DropGraph(query) => cache_scope_insert(scopes, &query.name),
1439        QueryExpr::DropVector(query) => cache_scope_insert(scopes, &query.name),
1440        QueryExpr::DropDocument(query) => cache_scope_insert(scopes, &query.name),
1441        QueryExpr::DropKv(query) => cache_scope_insert(scopes, &query.name),
1442        QueryExpr::DropCollection(query) => cache_scope_insert(scopes, &query.name),
1443        QueryExpr::Truncate(query) => cache_scope_insert(scopes, &query.name),
1444        QueryExpr::AlterTable(query) => cache_scope_insert(scopes, &query.name),
1445        QueryExpr::CreateVcsRef(_) | QueryExpr::DropVcsRef(_) => {
1446            cache_scope_insert(scopes, crate::application::vcs_collections::REFS)
1447        }
1448        QueryExpr::CreateIndex(query) => cache_scope_insert(scopes, &query.table),
1449        QueryExpr::DropIndex(query) => cache_scope_insert(scopes, &query.table),
1450        QueryExpr::CreateTimeSeries(query) => cache_scope_insert(scopes, &query.name),
1451        QueryExpr::CreateMetric(query) => cache_scope_insert(scopes, &query.path),
1452        QueryExpr::AlterMetric(query) => cache_scope_insert(scopes, &query.path),
1453        QueryExpr::CreateSlo(query) => cache_scope_insert(scopes, &query.path),
1454        QueryExpr::DropTimeSeries(query) => cache_scope_insert(scopes, &query.name),
1455        QueryExpr::CreateQueue(query) => cache_scope_insert(scopes, &query.name),
1456        QueryExpr::AlterQueue(query) => cache_scope_insert(scopes, &query.name),
1457        QueryExpr::DropQueue(query) => cache_scope_insert(scopes, &query.name),
1458        QueryExpr::QueueSelect(query) => cache_scope_insert(scopes, &query.queue),
1459        QueryExpr::QueueCommand(query) => match query {
1460            QueueCommand::Push { queue, .. }
1461            | QueueCommand::Pop { queue, .. }
1462            | QueueCommand::Peek { queue, .. }
1463            | QueueCommand::Len { queue }
1464            | QueueCommand::Purge { queue }
1465            | QueueCommand::GroupCreate { queue, .. }
1466            | QueueCommand::GroupRead { queue, .. }
1467            | QueueCommand::Pending { queue, .. }
1468            | QueueCommand::Claim { queue, .. }
1469            | QueueCommand::Ack { queue, .. }
1470            | QueueCommand::Nack { queue, .. } => cache_scope_insert(scopes, queue),
1471            QueueCommand::Move {
1472                source,
1473                destination,
1474                ..
1475            } => {
1476                cache_scope_insert(scopes, source);
1477                cache_scope_insert(scopes, destination);
1478            }
1479        },
1480        QueryExpr::EventsBackfill(query) => {
1481            cache_scope_insert(scopes, &query.collection);
1482            cache_scope_insert(scopes, &query.target_queue);
1483        }
1484        QueryExpr::CreateTree(query) => cache_scope_insert(scopes, &query.collection),
1485        QueryExpr::DropTree(query) => cache_scope_insert(scopes, &query.collection),
1486        QueryExpr::TreeCommand(query) => match query {
1487            TreeCommand::Insert { collection, .. }
1488            | TreeCommand::Move { collection, .. }
1489            | TreeCommand::Delete { collection, .. }
1490            | TreeCommand::Validate { collection, .. }
1491            | TreeCommand::Rebalance { collection, .. } => cache_scope_insert(scopes, collection),
1492        },
1493        QueryExpr::SearchCommand(query) => match query {
1494            SearchCommand::Similar { collection, .. }
1495            | SearchCommand::Hybrid { collection, .. }
1496            | SearchCommand::SpatialRadius { collection, .. }
1497            | SearchCommand::SpatialBbox { collection, .. }
1498            | SearchCommand::SpatialNearest { collection, .. } => {
1499                cache_scope_insert(scopes, collection);
1500            }
1501            SearchCommand::Text { collection, .. }
1502            | SearchCommand::Multimodal { collection, .. }
1503            | SearchCommand::Index { collection, .. }
1504            | SearchCommand::Context { collection, .. } => {
1505                if let Some(collection) = collection.as_deref() {
1506                    cache_scope_insert(scopes, collection);
1507                }
1508            }
1509        },
1510        QueryExpr::Ask(query) => {
1511            if let Some(collection) = query.collection.as_deref() {
1512                cache_scope_insert(scopes, collection);
1513            }
1514        }
1515        QueryExpr::ExplainAlter(query) => cache_scope_insert(scopes, &query.target.name),
1516        QueryExpr::MaintenanceCommand(cmd) => match cmd {
1517            crate::storage::query::ast::MaintenanceCommand::Vacuum { target, .. }
1518            | crate::storage::query::ast::MaintenanceCommand::Analyze { target } => {
1519                if let Some(t) = target {
1520                    cache_scope_insert(scopes, t);
1521                }
1522            }
1523        },
1524        QueryExpr::CopyFrom(cmd) => cache_scope_insert(scopes, &cmd.table),
1525        QueryExpr::CreateView(cmd) => {
1526            cache_scope_insert(scopes, &cmd.name);
1527            // Invalidating the view should also invalidate its dependencies.
1528            collect_query_expr_result_cache_scopes(scopes, &cmd.query);
1529        }
1530        QueryExpr::DropView(cmd) => cache_scope_insert(scopes, &cmd.name),
1531        QueryExpr::RefreshMaterializedView(cmd) => cache_scope_insert(scopes, &cmd.name),
1532        QueryExpr::CreatePolicy(cmd) => cache_scope_insert(scopes, &cmd.table),
1533        QueryExpr::DropPolicy(cmd) => cache_scope_insert(scopes, &cmd.table),
1534        QueryExpr::CreateServer(_) | QueryExpr::DropServer(_) => {}
1535        QueryExpr::CreateForeignTable(cmd) => cache_scope_insert(scopes, &cmd.name),
1536        QueryExpr::DropForeignTable(cmd) => cache_scope_insert(scopes, &cmd.name),
1537        QueryExpr::Graph(_)
1538        | QueryExpr::GraphCommand(_)
1539        | QueryExpr::ProbabilisticCommand(_)
1540        | QueryExpr::SetConfig { .. }
1541        | QueryExpr::ShowConfig { .. }
1542        | QueryExpr::SetSecret { .. }
1543        | QueryExpr::DeleteSecret { .. }
1544        | QueryExpr::ShowSecrets { .. }
1545        | QueryExpr::SetTenant(_)
1546        | QueryExpr::ShowTenant
1547        | QueryExpr::TransactionControl(_)
1548        | QueryExpr::CreateSchema(_)
1549        | QueryExpr::DropSchema(_)
1550        | QueryExpr::CreateSequence(_)
1551        | QueryExpr::DropSequence(_)
1552        | QueryExpr::Grant(_)
1553        | QueryExpr::Revoke(_)
1554        | QueryExpr::AlterUser(_)
1555        | QueryExpr::CreateUser(_)
1556        | QueryExpr::CreateIamPolicy { .. }
1557        | QueryExpr::DropIamPolicy { .. }
1558        | QueryExpr::AttachPolicy { .. }
1559        | QueryExpr::DetachPolicy { .. }
1560        | QueryExpr::ShowPolicies { .. }
1561        | QueryExpr::ShowEffectivePermissions { .. }
1562        | QueryExpr::RankOf(_)
1563        | QueryExpr::ApproxRankOf(_)
1564        | QueryExpr::RankRange(_)
1565        | QueryExpr::SimulatePolicy { .. }
1566        | QueryExpr::LintPolicy { .. }
1567        | QueryExpr::MigratePolicyMode { .. }
1568        | QueryExpr::CreateMigration(_)
1569        | QueryExpr::ApplyMigration(_)
1570        | QueryExpr::RollbackMigration(_)
1571        | QueryExpr::ExplainMigration(_)
1572        | QueryExpr::EventsBackfillStatus { .. } => {}
1573        QueryExpr::KvCommand(cmd) => {
1574            use crate::storage::query::ast::KvCommand;
1575            match cmd {
1576                KvCommand::Put { collection, .. }
1577                | KvCommand::InvalidateTags { collection, .. }
1578                | KvCommand::Get { collection, .. }
1579                | KvCommand::Unseal { collection, .. }
1580                | KvCommand::Rotate { collection, .. }
1581                | KvCommand::History { collection, .. }
1582                | KvCommand::List { collection, .. }
1583                | KvCommand::Purge { collection, .. }
1584                | KvCommand::Watch { collection, .. }
1585                | KvCommand::Delete { collection, .. }
1586                | KvCommand::Incr { collection, .. }
1587                | KvCommand::Cas { collection, .. } => cache_scope_insert(scopes, collection),
1588            }
1589        }
1590        QueryExpr::ConfigCommand(cmd) => {
1591            use crate::storage::query::ast::ConfigCommand;
1592            match cmd {
1593                ConfigCommand::Put { collection, .. }
1594                | ConfigCommand::Get { collection, .. }
1595                | ConfigCommand::Resolve { collection, .. }
1596                | ConfigCommand::Rotate { collection, .. }
1597                | ConfigCommand::Delete { collection, .. }
1598                | ConfigCommand::History { collection, .. }
1599                | ConfigCommand::List { collection, .. }
1600                | ConfigCommand::Watch { collection, .. }
1601                | ConfigCommand::InvalidVolatileOperation { collection, .. } => {
1602                    cache_scope_insert(scopes, collection)
1603                }
1604            }
1605        }
1606        _ => {}
1607    }
1608}
1609
1610/// Combine matching RLS policies for a table + action into a single
1611/// `Filter` suitable for AND-ing into a caller's `WHERE` clause.
1612///
1613/// Returns `None` when RLS is disabled or no policy admits the caller's
1614/// role — callers use that to short-circuit the mutation (for DELETE /
1615/// UPDATE we simply skip the operation, which PG expresses as "no rows
1616/// match the policy + predicate combination").
1617pub(crate) fn rls_policy_filter(
1618    runtime: &RedDBRuntime,
1619    table: &str,
1620    action: crate::storage::query::ast::PolicyAction,
1621) -> Option<crate::storage::query::ast::Filter> {
1622    rls_policy_filter_for_kind(
1623        runtime,
1624        table,
1625        action,
1626        crate::storage::query::ast::PolicyTargetKind::Table,
1627    )
1628}
1629
1630/// Kind-aware policy filter combiner (Phase 2.5.5 RLS universal).
1631/// Graph / vector / queue / timeseries scans pass the concrete kind;
1632/// policies targeting other kinds are ignored. Legacy Table-scoped
1633/// policies still apply cross-kind — callers register auto-tenancy
1634/// policies as Table today.
1635pub(crate) fn rls_policy_filter_for_kind(
1636    runtime: &RedDBRuntime,
1637    table: &str,
1638    action: crate::storage::query::ast::PolicyAction,
1639    kind: crate::storage::query::ast::PolicyTargetKind,
1640) -> Option<crate::storage::query::ast::Filter> {
1641    use crate::storage::query::ast::Filter;
1642
1643    if !runtime.inner.rls_enabled_tables.read().contains(table) {
1644        return None;
1645    }
1646    let role = current_auth_identity().map(|(_, role)| role);
1647    let role_str = role.map(|r| r.as_str().to_string());
1648    let policies = runtime.matching_rls_policies_for_kind(table, role_str.as_deref(), action, kind);
1649    if policies.is_empty() {
1650        return None;
1651    }
1652    policies
1653        .into_iter()
1654        .reduce(|acc, f| Filter::Or(Box::new(acc), Box::new(f)))
1655}
1656
1657/// Returns true when the table has RLS enforcement enabled. Convenience
1658/// shortcut so DML paths can gate the AND-combine work without reaching
1659/// into `runtime.inner.rls_enabled_tables` directly.
1660pub(crate) fn rls_is_enabled(runtime: &RedDBRuntime, table: &str) -> bool {
1661    runtime.inner.rls_enabled_tables.read().contains(table)
1662}
1663
1664/// Per-entity gate used by the graph materialiser for `GraphNode`
1665/// entities. RLS is checked against the source collection with
1666/// `kind = Nodes`, which `matching_rls_policies_for_kind` resolves to
1667/// either `Nodes`-targeted policies or legacy `Table`-targeted ones
1668/// (for back-compat with auto-tenancy declarations). Cached per
1669/// collection so big graphs only resolve the policy chain once.
1670fn node_passes_rls(
1671    runtime: &RedDBRuntime,
1672    collection: &str,
1673    role: Option<&str>,
1674    cache: &mut std::collections::HashMap<String, Option<crate::storage::query::ast::Filter>>,
1675    entity: &crate::storage::unified::entity::UnifiedEntity,
1676) -> bool {
1677    use crate::storage::query::ast::{Filter, PolicyAction, PolicyTargetKind};
1678
1679    if !runtime.inner.rls_enabled_tables.read().contains(collection) {
1680        return true;
1681    }
1682    let filter = cache.entry(collection.to_string()).or_insert_with(|| {
1683        let policies = runtime.matching_rls_policies_for_kind(
1684            collection,
1685            role,
1686            PolicyAction::Select,
1687            PolicyTargetKind::Nodes,
1688        );
1689        if policies.is_empty() {
1690            None
1691        } else {
1692            policies
1693                .into_iter()
1694                .reduce(|acc, f| Filter::Or(Box::new(acc), Box::new(f)))
1695        }
1696    });
1697    let Some(filter) = filter else {
1698        return false;
1699    };
1700    crate::runtime::query_exec::evaluate_entity_filter_with_db(
1701        Some(&runtime.inner.db),
1702        entity,
1703        filter,
1704        collection,
1705        collection,
1706    )
1707}
1708
1709/// Edge counterpart of `node_passes_rls`. Same caching strategy with
1710/// `kind = Edges`.
1711fn edge_passes_rls(
1712    runtime: &RedDBRuntime,
1713    collection: &str,
1714    role: Option<&str>,
1715    cache: &mut std::collections::HashMap<String, Option<crate::storage::query::ast::Filter>>,
1716    entity: &crate::storage::unified::entity::UnifiedEntity,
1717) -> bool {
1718    use crate::storage::query::ast::{Filter, PolicyAction, PolicyTargetKind};
1719
1720    if !runtime.inner.rls_enabled_tables.read().contains(collection) {
1721        return true;
1722    }
1723    let filter = cache.entry(collection.to_string()).or_insert_with(|| {
1724        let policies = runtime.matching_rls_policies_for_kind(
1725            collection,
1726            role,
1727            PolicyAction::Select,
1728            PolicyTargetKind::Edges,
1729        );
1730        if policies.is_empty() {
1731            None
1732        } else {
1733            policies
1734                .into_iter()
1735                .reduce(|acc, f| Filter::Or(Box::new(acc), Box::new(f)))
1736        }
1737    });
1738    let Some(filter) = filter else {
1739        return false;
1740    };
1741    crate::runtime::query_exec::evaluate_entity_filter_with_db(
1742        Some(&runtime.inner.db),
1743        entity,
1744        filter,
1745        collection,
1746        collection,
1747    )
1748}
1749
1750/// RLS policy injection (Phase 2.5.2 PG parity).
1751///
1752/// Fetch every matching policy for the current thread-local role and
1753/// fold them into the query's filter. Semantics mirror PostgreSQL:
1754///
1755/// * Multiple policies on the same table combine with **OR** — a row is
1756///   visible if *any* policy admits it.
1757/// * The combined policy predicate is **AND**-ed into the caller's
1758///   existing `WHERE` clause so explicit predicates continue to trim
1759///   the policy-allowed set.
1760/// * No matching policies + RLS enabled = zero rows (PG's
1761///   restrictive-default). Callers get `None` and return an empty
1762///   `UnifiedResult` without ever dispatching the scan.
1763///
1764/// This runs only when `RuntimeInner::rls_enabled_tables` already
1765/// contains the table name — callers gate the hot path upfront to
1766/// avoid the lock acquisition on tables without RLS.
1767///
1768/// Returns `None` when no policy admits the current role; returns
1769/// `Some(mutated_table)` with policy filters folded in otherwise.
1770fn inject_rls_filters(
1771    runtime: &RedDBRuntime,
1772    frame: &dyn super::statement_frame::ReadFrame,
1773    mut table: crate::storage::query::ast::TableQuery,
1774) -> Option<crate::storage::query::ast::TableQuery> {
1775    use crate::storage::query::ast::{Filter, PolicyAction};
1776
1777    // `None` role falls through to policies with no `TO role` clause.
1778    let role = frame.identity().map(|(_, role)| role);
1779    let role_str = role.map(|r| r.as_str().to_string());
1780    let policies =
1781        runtime.matching_rls_policies(&table.table, role_str.as_deref(), PolicyAction::Select);
1782
1783    if policies.is_empty() {
1784        // RLS enabled + no policy match = deny everything. Signal the
1785        // caller to short-circuit with an empty result set.
1786        return None;
1787    }
1788
1789    // Combine policy predicates with OR (PG's permissive default).
1790    let combined = policies
1791        .into_iter()
1792        .reduce(|acc, f| Filter::Or(Box::new(acc), Box::new(f)))
1793        .expect("policies non-empty");
1794
1795    // AND into the caller's existing predicate. The predicate may live
1796    // in `where_expr` rather than `filter`: `resolve_table_expr_subqueries`
1797    // nulls `filter` whenever `where_expr` is present (the case for a
1798    // view body rewritten into `SELECT … WHERE …`). Folding only into
1799    // `filter` here would silently drop that `where_expr` predicate at
1800    // eval time because `effective_table_filter` prefers `filter` —
1801    // e.g. `WITHIN TENANT … SELECT * FROM <view>` would apply the
1802    // tenant policy but lose the view's own WHERE (#635).
1803    use crate::storage::query::sql_lowering::{expr_to_filter, filter_to_expr};
1804    let had_where_expr = table.where_expr.is_some();
1805    let existing = table
1806        .filter
1807        .take()
1808        .or_else(|| table.where_expr.as_ref().map(expr_to_filter));
1809    let new_filter = match existing {
1810        Some(existing) => Filter::And(Box::new(existing), Box::new(combined)),
1811        None => combined,
1812    };
1813    // Keep `where_expr` in lock-step with the merged `filter` so
1814    // whichever the executor consults sees the full predicate.
1815    if had_where_expr {
1816        table.where_expr = Some(filter_to_expr(&new_filter));
1817    }
1818    table.filter = Some(new_filter);
1819    Some(table)
1820}
1821
1822/// Apply per-table RLS to a `JoinQuery` by folding each side's policy
1823/// predicate into the join's outer filter. Walking the merged record
1824/// at the join layer (rather than mutating the per-side scan filter)
1825/// keeps the planner's strategy choice and per-side index selection
1826/// undisturbed — the policy predicate uses the qualified `t.col` form
1827/// that resolves cleanly against the merged record's keys.
1828///
1829/// Returns `None` when any leaf has RLS enabled and no policy admits
1830/// the caller — the join short-circuits to an empty result.
1831fn inject_rls_into_join(
1832    runtime: &RedDBRuntime,
1833    frame: &dyn super::statement_frame::ReadFrame,
1834    mut join: crate::storage::query::ast::JoinQuery,
1835) -> Option<crate::storage::query::ast::JoinQuery> {
1836    use crate::storage::query::ast::Filter;
1837
1838    let mut policy_filters: Vec<Filter> = Vec::new();
1839    if !collect_join_side_policy(runtime, frame, join.left.as_ref(), &mut policy_filters) {
1840        return None;
1841    }
1842    if !collect_join_side_policy(runtime, frame, join.right.as_ref(), &mut policy_filters) {
1843        return None;
1844    }
1845
1846    if policy_filters.is_empty() {
1847        return Some(join);
1848    }
1849
1850    let combined = policy_filters
1851        .into_iter()
1852        .reduce(|acc, f| Filter::And(Box::new(acc), Box::new(f)))
1853        .expect("policy_filters non-empty");
1854
1855    join.filter = Some(match join.filter.take() {
1856        Some(existing) => Filter::And(Box::new(existing), Box::new(combined)),
1857        None => combined,
1858    });
1859
1860    Some(join)
1861}
1862
1863/// For each `Table` leaf reachable through nested joins, append the
1864/// RLS-policy filter (combined with OR across that side's matching
1865/// policies) into `out`. Returns `false` when a side has RLS enabled
1866/// but no policy admits the caller — the join must short-circuit.
1867fn collect_join_side_policy(
1868    runtime: &RedDBRuntime,
1869    frame: &dyn super::statement_frame::ReadFrame,
1870    expr: &crate::storage::query::ast::QueryExpr,
1871    out: &mut Vec<crate::storage::query::ast::Filter>,
1872) -> bool {
1873    use crate::storage::query::ast::{Filter, PolicyAction, QueryExpr};
1874    match expr {
1875        QueryExpr::Table(t) => {
1876            if !runtime.inner.rls_enabled_tables.read().contains(&t.table) {
1877                return true;
1878            }
1879            let role = frame.identity().map(|(_, role)| role);
1880            let role_str = role.map(|r| r.as_str().to_string());
1881            let policies =
1882                runtime.matching_rls_policies(&t.table, role_str.as_deref(), PolicyAction::Select);
1883            if policies.is_empty() {
1884                return false;
1885            }
1886            let combined = policies
1887                .into_iter()
1888                .reduce(|acc, f| Filter::Or(Box::new(acc), Box::new(f)))
1889                .expect("policies non-empty");
1890            out.push(combined);
1891            true
1892        }
1893        QueryExpr::Join(inner) => {
1894            collect_join_side_policy(runtime, frame, inner.left.as_ref(), out)
1895                && collect_join_side_policy(runtime, frame, inner.right.as_ref(), out)
1896        }
1897        _ => true,
1898    }
1899}
1900
1901/// Foreign-table post-scan filter application (Phase 3.2.2 PG parity).
1902///
1903/// Phase 3.2 FDW wrappers don't advertise filter pushdown, so the runtime
1904/// applies `WHERE` / `ORDER BY` / `LIMIT` / `OFFSET` after the wrapper
1905/// materialises all rows. Projections are best-effort — when the query
1906/// lists explicit columns we keep only those; a `SELECT *` keeps every
1907/// wrapper-emitted field verbatim.
1908///
1909/// When a wrapper later opts into pushdown (`supports_pushdown = true`)
1910/// the runtime will pass the compiled filter down instead of post-filtering.
1911fn apply_foreign_table_filters(
1912    records: Vec<crate::storage::query::unified::UnifiedRecord>,
1913    query: &crate::storage::query::ast::TableQuery,
1914) -> crate::storage::query::unified::UnifiedResult {
1915    use crate::storage::query::sql_lowering::{
1916        effective_table_filter, effective_table_projections,
1917    };
1918    use crate::storage::query::unified::UnifiedResult;
1919
1920    let filter = effective_table_filter(query);
1921    let projections = effective_table_projections(query);
1922
1923    // Step 1 — WHERE. Reuse the cross-store evaluator so the semantics
1924    // match native-collection queries (same operators, same NULL handling).
1925    let mut filtered: Vec<_> = records
1926        .into_iter()
1927        .filter(|record| match &filter {
1928            Some(f) => {
1929                super::join_filter::evaluate_runtime_filter_with_db(None, record, f, None, None)
1930            }
1931            None => true,
1932        })
1933        .collect();
1934
1935    // Step 2 — LIMIT / OFFSET. Applied after filter to match SQL semantics.
1936    if let Some(offset) = query.offset {
1937        let offset = offset as usize;
1938        if offset >= filtered.len() {
1939            filtered.clear();
1940        } else {
1941            filtered.drain(0..offset);
1942        }
1943    }
1944    if let Some(limit) = query.limit {
1945        filtered.truncate(limit as usize);
1946    }
1947
1948    // Step 3 — columns list. `SELECT *` (no explicit projections) keeps
1949    // the wrapper's column set; an explicit list trims to those names.
1950    let columns: Vec<String> = if projections.is_empty() {
1951        filtered
1952            .first()
1953            .map(|r| r.column_names().iter().map(|k| k.to_string()).collect())
1954            .unwrap_or_default()
1955    } else {
1956        projections
1957            .iter()
1958            .map(super::join_filter::projection_name)
1959            .collect()
1960    };
1961
1962    let mut result = UnifiedResult::empty();
1963    result.columns = columns;
1964    result.records = filtered;
1965    result
1966}
1967
1968/// Collect every concrete table reference inside a `QueryExpr`.
1969///
1970/// Used by view bookkeeping (dependency tracking for materialised
1971/// invalidation) and any other rewriter that needs to know the base
1972/// tables a query pulls from. Does not descend into projections/filters;
1973/// only the `FROM` side.
1974pub(crate) fn collect_table_refs(expr: &QueryExpr) -> Vec<String> {
1975    let mut scopes: HashSet<String> = HashSet::new();
1976    collect_query_expr_result_cache_scopes(&mut scopes, expr);
1977    scopes.into_iter().collect()
1978}
1979
1980fn query_expr_result_cache_scopes(expr: &QueryExpr) -> HashSet<String> {
1981    let mut scopes = HashSet::new();
1982    collect_query_expr_result_cache_scopes(&mut scopes, expr);
1983    scopes
1984}
1985
1986/// Heuristic: does the raw SQL reference a built-in whose output
1987/// varies by connection, clock, or randomness? Such queries must
1988/// skip the 30s result cache — see the call site for rationale.
1989///
1990/// ASCII case-insensitive substring match. False positives (the
1991/// token appears in a quoted string) only skip caching, which is
1992/// the conservative direction.
1993/// If `sql` starts with `EXPLAIN` followed by a generic explainable statement,
1994/// return the trimmed inner statement; otherwise `None`.
1995///
1996/// `EXPLAIN ALTER FOR CREATE TABLE ...` is a separate schema-diff
1997/// command handled inside the normal SQL parser, so we leave it
1998/// alone here. `EXPLAIN ASK` and `EXPLAIN MIGRATION` are also executable
1999/// read paths handled by the parser/runtime directly.
2000fn strip_explain_prefix(sql: &str) -> Option<&str> {
2001    let trimmed = sql.trim_start();
2002    let (head, rest) = trimmed.split_at(
2003        trimmed
2004            .find(|c: char| c.is_whitespace())
2005            .unwrap_or(trimmed.len()),
2006    );
2007    if !head.eq_ignore_ascii_case("EXPLAIN") {
2008        return None;
2009    }
2010    let rest = rest.trim_start();
2011    if rest.is_empty() {
2012        return None;
2013    }
2014    // Peek the next token; command-specific EXPLAIN forms defer to
2015    // the normal parser.
2016    let next_head_end = rest.find(|c: char| c.is_whitespace()).unwrap_or(rest.len());
2017    if rest[..next_head_end].eq_ignore_ascii_case("ALTER")
2018        || rest[..next_head_end].eq_ignore_ascii_case("ASK")
2019        || rest[..next_head_end].eq_ignore_ascii_case("MIGRATION")
2020    {
2021        return None;
2022    }
2023    Some(rest)
2024}
2025
2026fn parse_vcs_author(raw: &str) -> crate::application::vcs::Author {
2027    let trimmed = raw.trim();
2028    if let Some((name, rest)) = trimmed.rsplit_once('<') {
2029        if let Some(email) = rest.strip_suffix('>') {
2030            return crate::application::vcs::Author {
2031                name: name.trim().to_string(),
2032                email: email.trim().to_string(),
2033            };
2034        }
2035    }
2036    crate::application::vcs::Author {
2037        name: trimmed.to_string(),
2038        email: String::new(),
2039    }
2040}
2041
2042fn looks_like_commit_hash(value: &str) -> bool {
2043    value.len() >= 7 && value.bytes().all(|byte| byte.is_ascii_hexdigit())
2044}
2045
2046enum RuntimeVcsCommand {
2047    Checkpoint {
2048        message: String,
2049        author: Option<String>,
2050    },
2051    Checkout {
2052        target: String,
2053    },
2054    Reset {
2055        mode: RuntimeVcsResetMode,
2056        target: String,
2057    },
2058    Merge {
2059        branch: String,
2060    },
2061    CherryPick {
2062        commit: String,
2063    },
2064    Revert {
2065        commit: String,
2066    },
2067    ResolveConflict {
2068        key: String,
2069        resolution: RuntimeVcsConflictResolution,
2070    },
2071}
2072
2073enum RuntimeVcsResetMode {
2074    Hard,
2075    Soft,
2076    Mixed,
2077}
2078
2079enum RuntimeVcsConflictResolution {
2080    Ours,
2081    Theirs,
2082}
2083
2084fn strip_keyword_ci<'a>(input: &'a str, keyword: &str) -> Option<&'a str> {
2085    let trimmed = input.trim_start();
2086    if trimmed.len() < keyword.len() || !trimmed[..keyword.len()].eq_ignore_ascii_case(keyword) {
2087        return None;
2088    }
2089    let rest = &trimmed[keyword.len()..];
2090    if rest.is_empty() || rest.starts_with(char::is_whitespace) {
2091        Some(rest.trim_start())
2092    } else {
2093        None
2094    }
2095}
2096
2097fn parse_vcs_quoted(input: &str) -> RedDBResult<(String, &str)> {
2098    let trimmed = input.trim_start();
2099    let rest = trimmed
2100        .strip_prefix('\'')
2101        .ok_or_else(|| RedDBError::Query("expected quoted string".to_string()))?;
2102    let end = rest
2103        .find('\'')
2104        .ok_or_else(|| RedDBError::Query("unterminated quoted string".to_string()))?;
2105    Ok((rest[..end].to_string(), rest[end + 1..].trim_start()))
2106}
2107
2108fn parse_vcs_atom(input: &str) -> RedDBResult<(String, &str)> {
2109    let trimmed = input.trim_start();
2110    if trimmed.starts_with('\'') {
2111        return parse_vcs_quoted(trimmed);
2112    }
2113    let end = trimmed.find(char::is_whitespace).unwrap_or(trimmed.len());
2114    if end == 0 {
2115        return Err(RedDBError::Query("expected VCS argument".to_string()));
2116    }
2117    Ok((trimmed[..end].to_string(), trimmed[end..].trim_start()))
2118}
2119
2120fn expect_vcs_end(rest: &str) -> RedDBResult<()> {
2121    if rest.trim().is_empty() {
2122        Ok(())
2123    } else {
2124        Err(RedDBError::Query(format!(
2125            "unexpected token after VCS command: {}",
2126            rest.trim()
2127        )))
2128    }
2129}
2130
2131fn parse_runtime_vcs_command(query: &str) -> Option<RedDBResult<RuntimeVcsCommand>> {
2132    let trimmed = query.trim_start();
2133    if let Some(rest) = strip_keyword_ci(trimmed, "CHECKPOINT") {
2134        return Some((|| {
2135            let (message, rest) = parse_vcs_quoted(rest)?;
2136            let rest = rest.trim_start();
2137            let author = if let Some(author_rest) = strip_keyword_ci(rest, "AUTHOR") {
2138                let (author, rest) = parse_vcs_quoted(author_rest)?;
2139                expect_vcs_end(rest)?;
2140                Some(author)
2141            } else {
2142                expect_vcs_end(rest)?;
2143                None
2144            };
2145            Ok(RuntimeVcsCommand::Checkpoint { message, author })
2146        })());
2147    }
2148    if let Some(rest) = strip_keyword_ci(trimmed, "CHECKOUT") {
2149        return Some((|| {
2150            let (target, rest) = parse_vcs_atom(rest)?;
2151            expect_vcs_end(rest)?;
2152            Ok(RuntimeVcsCommand::Checkout { target })
2153        })());
2154    }
2155    if let Some(rest) = strip_keyword_ci(trimmed, "RESET") {
2156        if strip_keyword_ci(rest, "TENANT").is_some() {
2157            return None;
2158        }
2159        return Some((|| {
2160            let mut rest = rest.trim_start();
2161            let mode = if let Some(next) = strip_keyword_ci(rest, "HARD") {
2162                rest = next;
2163                RuntimeVcsResetMode::Hard
2164            } else if let Some(next) = strip_keyword_ci(rest, "SOFT") {
2165                rest = next;
2166                RuntimeVcsResetMode::Soft
2167            } else if let Some(next) = strip_keyword_ci(rest, "MIXED") {
2168                rest = next;
2169                RuntimeVcsResetMode::Mixed
2170            } else {
2171                RuntimeVcsResetMode::Mixed
2172            };
2173            let rest = strip_keyword_ci(rest, "TO")
2174                .ok_or_else(|| RedDBError::Query("expected TO in RESET".to_string()))?;
2175            let (target, rest) = parse_vcs_atom(rest)?;
2176            expect_vcs_end(rest)?;
2177            Ok(RuntimeVcsCommand::Reset { mode, target })
2178        })());
2179    }
2180    if let Some(rest) = strip_keyword_ci(trimmed, "MERGE") {
2181        return Some((|| {
2182            let (branch, rest) = parse_vcs_atom(rest)?;
2183            expect_vcs_end(rest)?;
2184            Ok(RuntimeVcsCommand::Merge { branch })
2185        })());
2186    }
2187    if let Some(rest) = strip_keyword_ci(trimmed, "CHERRY") {
2188        return Some((|| {
2189            let rest = strip_keyword_ci(rest, "PICK")
2190                .ok_or_else(|| RedDBError::Query("expected PICK in CHERRY PICK".to_string()))?;
2191            let (commit, rest) = parse_vcs_atom(rest)?;
2192            expect_vcs_end(rest)?;
2193            Ok(RuntimeVcsCommand::CherryPick { commit })
2194        })());
2195    }
2196    if let Some(rest) = strip_keyword_ci(trimmed, "REVERT") {
2197        return Some((|| {
2198            let (commit, rest) = parse_vcs_atom(rest)?;
2199            expect_vcs_end(rest)?;
2200            Ok(RuntimeVcsCommand::Revert { commit })
2201        })());
2202    }
2203    if let Some(rest) = strip_keyword_ci(trimmed, "RESOLVE") {
2204        // Only `RESOLVE CONFLICT …` is a VCS working-set verb. Plain RESOLVE /
2205        // `RESOLVE CONFIG …` (config secret resolution) must fall through to the
2206        // normal query surface — the `?` early-returns None for non-CONFLICT,
2207        // mirroring the RESET/TENANT disambiguation above.
2208        let rest = strip_keyword_ci(rest, "CONFLICT")?;
2209        return Some((|| {
2210            let (key, rest) = parse_vcs_quoted(rest)?;
2211            let rest = strip_keyword_ci(rest, "USING")
2212                .ok_or_else(|| RedDBError::Query("expected USING in RESOLVE".to_string()))?;
2213            let (resolution, rest) = if let Some(rest) = strip_keyword_ci(rest, "OURS") {
2214                (RuntimeVcsConflictResolution::Ours, rest)
2215            } else if let Some(rest) = strip_keyword_ci(rest, "THEIRS") {
2216                (RuntimeVcsConflictResolution::Theirs, rest)
2217            } else {
2218                return Err(RedDBError::Query(
2219                    "expected OURS or THEIRS in RESOLVE".to_string(),
2220                ));
2221            };
2222            expect_vcs_end(rest)?;
2223            Ok(RuntimeVcsCommand::ResolveConflict { key, resolution })
2224        })());
2225    }
2226    None
2227}
2228
2229/// Cheap prefix check for a leading `WITH` keyword. Used to gate the
2230/// CTE-aware parse in `execute_query` without paying for a full
2231/// lexer pass on every statement. Treats `WITHIN` as not-a-CTE so
2232/// `WITHIN TENANT '...' SELECT ...` doesn't mis-route.
2233pub(super) fn has_with_prefix(sql: &str) -> bool {
2234    let trimmed = sql.trim_start();
2235    let head_end = trimmed
2236        .find(|c: char| c.is_whitespace() || c == '(')
2237        .unwrap_or(trimmed.len());
2238    trimmed[..head_end].eq_ignore_ascii_case("WITH")
2239}
2240
2241/// If the query is a plain SELECT whose top-level `TableQuery`
2242/// carries an `AS OF` clause, return a typed spec that the runtime
2243/// can feed to `vcs_resolve_as_of`. Returns `None` for any other
2244/// shape — joins, DML, EXPLAIN, or parse failures — so callers fall
2245/// back to the connection's regular MVCC snapshot. A cheap textual
2246/// prefilter skips the parse entirely when the source doesn't
2247/// mention `AS OF` / `as of`, keeping the autocommit hot path free.
2248fn peek_top_level_as_of(sql: &str) -> Option<crate::application::vcs::AsOfSpec> {
2249    peek_top_level_as_of_with_table(sql).map(|(spec, _)| spec)
2250}
2251
2252/// Same as `peek_top_level_as_of` but also returns the table name
2253/// targeted by the AS OF clause (when the FROM clause names a
2254/// concrete table). `None` for the table slot means scalar SELECT
2255/// or a subquery source — callers treat those as "no enforcement".
2256pub(super) fn peek_top_level_as_of_with_table(
2257    sql: &str,
2258) -> Option<(crate::application::vcs::AsOfSpec, Option<String>)> {
2259    if !sql
2260        .as_bytes()
2261        .windows(5)
2262        .any(|w| w.eq_ignore_ascii_case(b"as of"))
2263    {
2264        return None;
2265    }
2266    let parsed = crate::storage::query::parser::parse(sql).ok()?;
2267    let crate::storage::query::ast::QueryExpr::Table(table) = parsed.query else {
2268        return None;
2269    };
2270    let clause = table.as_of?;
2271    let table_name = if table.table.is_empty() || table.table == "any" {
2272        None
2273    } else {
2274        Some(table.table.clone())
2275    };
2276    let spec = match clause {
2277        crate::storage::query::ast::AsOfClause::Commit(h) => {
2278            crate::application::vcs::AsOfSpec::Commit(h)
2279        }
2280        crate::storage::query::ast::AsOfClause::Branch(b) => {
2281            crate::application::vcs::AsOfSpec::Branch(b)
2282        }
2283        crate::storage::query::ast::AsOfClause::Tag(t) => crate::application::vcs::AsOfSpec::Tag(t),
2284        crate::storage::query::ast::AsOfClause::TimestampMs(ts) => {
2285            crate::application::vcs::AsOfSpec::TimestampMs(ts)
2286        }
2287        crate::storage::query::ast::AsOfClause::Snapshot(x) => {
2288            crate::application::vcs::AsOfSpec::Snapshot(x)
2289        }
2290    };
2291    Some((spec, table_name))
2292}
2293
2294pub(super) fn query_has_volatile_builtin(sql: &str) -> bool {
2295    // Lowercase the bytes up to the first null/newline into a small
2296    // stack buffer for cheap contains() checks. Most SQL fits in the
2297    // buffer; longer queries fall back to owned lowercase.
2298    const VOLATILE_TOKENS: &[&str] = &[
2299        "pg_advisory_lock",
2300        "pg_try_advisory_lock",
2301        "pg_advisory_unlock",
2302        "random()",
2303        // `$config.<path>` / `$secret.<path>` resolve mutable runtime config /
2304        // vault state at execution time (#1370). A cached result would serve a
2305        // stale value after a later `SET CONFIG` / `SET SECRET`, so treat any
2306        // query referencing them as volatile (never result-cache it).
2307        "$config",
2308        "$secret",
2309        // NOW() / CURRENT_TIMESTAMP / CURRENT_DATE intentionally
2310        // omitted for now — they ARE volatile but today's tests rely
2311        // on caching them. Revisit once a tighter volatility story
2312        // lands.
2313    ];
2314    let lowered = sql.to_ascii_lowercase();
2315    VOLATILE_TOKENS.iter().any(|t| lowered.contains(t))
2316}
2317
2318pub(super) fn query_is_ask_statement(sql: &str) -> bool {
2319    let trimmed = sql.trim_start();
2320    let head_end = trimmed
2321        .find(|c: char| c.is_whitespace() || c == '(' || c == ';')
2322        .unwrap_or(trimmed.len());
2323    trimmed[..head_end].eq_ignore_ascii_case("ASK")
2324}
2325
2326/// Pick the `(global_mode, collection_mode)` pair for an expression,
2327/// or `None` for variants that opt out of intent-locking entirely
2328/// (admin statements like `SHOW CONFIG`, transaction control, tenant
2329/// toggles).
2330///
2331/// Phase-1 contract:
2332/// - Reads  — `(IX-compatible) (Global, IS) → (Collection, IS)`
2333/// - Writes — `(IX-compatible) (Global, IX) → (Collection, IX)`
2334/// - DDL    — `(strong)        (Global, IX) → (Collection, X)`
2335pub(super) fn intent_lock_modes_for(
2336    expr: &QueryExpr,
2337) -> Option<(
2338    crate::storage::transaction::lock::LockMode,
2339    crate::storage::transaction::lock::LockMode,
2340)> {
2341    use crate::storage::transaction::lock::LockMode::{Exclusive, IntentExclusive, IntentShared};
2342
2343    match expr {
2344        // Reads — IS / IS.
2345        QueryExpr::Table(_)
2346        | QueryExpr::Join(_)
2347        | QueryExpr::Vector(_)
2348        | QueryExpr::Hybrid(_)
2349        | QueryExpr::Graph(_)
2350        | QueryExpr::Path(_)
2351        | QueryExpr::Ask(_)
2352        | QueryExpr::SearchCommand(_)
2353        | QueryExpr::GraphCommand(_)
2354        | QueryExpr::RankOf(_)
2355        | QueryExpr::ApproxRankOf(_)
2356        | QueryExpr::RankRange(_)
2357        | QueryExpr::QueueSelect(_) => Some((IntentShared, IntentShared)),
2358
2359        // Writes — IX / IX. Non-tabular mutations (vector insert,
2360        // graph node insert, queue push, timeseries point insert)
2361        // don't carry their own dispatch arm here; they ride through
2362        // the Insert variant or a command variant covered by the
2363        // read-side arm above. P1.T4 expands only the TableQuery-ish
2364        // writes; non-tabular kinds inherit when their DML variants
2365        // land in later phases.
2366        QueryExpr::Insert(_)
2367        | QueryExpr::Update(_)
2368        | QueryExpr::Delete(_)
2369        | QueryExpr::QueueCommand(QueueCommand::Move { .. }) => {
2370            Some((IntentExclusive, IntentExclusive))
2371        }
2372        QueryExpr::QueueCommand(_) => Some((IntentShared, IntentShared)),
2373
2374        // DDL — IX / X. A DDL against collection `c` blocks all
2375        // other writers + readers on `c` but leaves other collections
2376        // running (because Global stays IX, not X).
2377        QueryExpr::CreateTable(_)
2378        | QueryExpr::CreateCollection(_)
2379        | QueryExpr::CreateVector(_)
2380        | QueryExpr::DropTable(_)
2381        | QueryExpr::DropGraph(_)
2382        | QueryExpr::DropVector(_)
2383        | QueryExpr::DropDocument(_)
2384        | QueryExpr::DropKv(_)
2385        | QueryExpr::DropCollection(_)
2386        | QueryExpr::Truncate(_)
2387        | QueryExpr::AlterTable(_)
2388        | QueryExpr::CreateIndex(_)
2389        | QueryExpr::DropIndex(_)
2390        | QueryExpr::CreateTimeSeries(_)
2391        | QueryExpr::CreateMetric(_)
2392        | QueryExpr::AlterMetric(_)
2393        | QueryExpr::CreateSlo(_)
2394        | QueryExpr::DropTimeSeries(_)
2395        | QueryExpr::CreateQueue(_)
2396        | QueryExpr::AlterQueue(_)
2397        | QueryExpr::DropQueue(_)
2398        | QueryExpr::CreateTree(_)
2399        | QueryExpr::DropTree(_)
2400        | QueryExpr::CreatePolicy(_)
2401        | QueryExpr::DropPolicy(_)
2402        | QueryExpr::CreateView(_)
2403        | QueryExpr::DropView(_)
2404        | QueryExpr::RefreshMaterializedView(_)
2405        | QueryExpr::CreateSchema(_)
2406        | QueryExpr::DropSchema(_)
2407        | QueryExpr::CreateSequence(_)
2408        | QueryExpr::DropSequence(_)
2409        | QueryExpr::CreateServer(_)
2410        | QueryExpr::DropServer(_)
2411        | QueryExpr::CreateForeignTable(_)
2412        | QueryExpr::DropForeignTable(_) => Some((IntentExclusive, Exclusive)),
2413
2414        // Admin / control — skip intent locks. `SET TENANT`,
2415        // `BEGIN / COMMIT / ROLLBACK`, `SET CONFIG`, `SHOW CONFIG`,
2416        // `VACUUM`, etc. don't touch collection data the same way
2417        // and the existing transaction layer already serialises the
2418        // pieces that matter.
2419        _ => None,
2420    }
2421}
2422
2423/// Best-effort collection inventory for an expression. Used to pick
2424/// `Collection(...)` resources for the intent-lock guard. Overshoots
2425/// are fine (take an extra IS, benign); undershoots leak writes past
2426/// DDL X locks, so err on the side of listing more names.
2427pub(super) fn collections_referenced(expr: &QueryExpr) -> Vec<String> {
2428    let mut out = Vec::new();
2429    walk_collections(expr, &mut out);
2430    out.sort();
2431    out.dedup();
2432    out
2433}
2434
2435fn walk_collections(expr: &QueryExpr, out: &mut Vec<String>) {
2436    match expr {
2437        QueryExpr::Table(t) => out.push(t.table.clone()),
2438        QueryExpr::Join(j) => {
2439            walk_collections(&j.left, out);
2440            walk_collections(&j.right, out);
2441        }
2442        QueryExpr::Insert(i) => out.push(i.table.clone()),
2443        QueryExpr::Update(u) => out.push(u.table.clone()),
2444        QueryExpr::Delete(d) => out.push(d.table.clone()),
2445        QueryExpr::QueueSelect(q) => out.push(q.queue.clone()),
2446
2447        // DDL — include the target collection so DDL takes
2448        // `(Collection, X)` and blocks concurrent readers / writers
2449        // on the same collection. Other collections stay live
2450        // because Global is still IX.
2451        QueryExpr::CreateTable(q) => out.push(q.name.clone()),
2452        QueryExpr::CreateCollection(q) => out.push(q.name.clone()),
2453        QueryExpr::CreateVector(q) => out.push(q.name.clone()),
2454        QueryExpr::DropTable(q) => out.push(q.name.clone()),
2455        QueryExpr::DropGraph(q) => out.push(q.name.clone()),
2456        QueryExpr::DropVector(q) => out.push(q.name.clone()),
2457        QueryExpr::DropDocument(q) => out.push(q.name.clone()),
2458        QueryExpr::DropKv(q) => out.push(q.name.clone()),
2459        QueryExpr::DropCollection(q) => out.push(q.name.clone()),
2460        QueryExpr::Truncate(q) => out.push(q.name.clone()),
2461        QueryExpr::AlterTable(q) => out.push(q.name.clone()),
2462        QueryExpr::CreateIndex(q) => out.push(q.table.clone()),
2463        QueryExpr::DropIndex(q) => out.push(q.table.clone()),
2464        QueryExpr::CreateTimeSeries(q) => out.push(q.name.clone()),
2465        QueryExpr::CreateMetric(q) => out.push(q.path.clone()),
2466        QueryExpr::AlterMetric(q) => out.push(q.path.clone()),
2467        QueryExpr::CreateSlo(q) => out.push(q.path.clone()),
2468        QueryExpr::DropTimeSeries(q) => out.push(q.name.clone()),
2469        QueryExpr::CreateQueue(q) => out.push(q.name.clone()),
2470        QueryExpr::AlterQueue(q) => out.push(q.name.clone()),
2471        QueryExpr::DropQueue(q) => out.push(q.name.clone()),
2472        QueryExpr::QueueCommand(QueueCommand::Move {
2473            source,
2474            destination,
2475            ..
2476        }) => {
2477            out.push(source.clone());
2478            out.push(destination.clone());
2479        }
2480        QueryExpr::CreatePolicy(q) => out.push(q.table.clone()),
2481        QueryExpr::CreateView(q) => out.push(q.name.clone()),
2482        QueryExpr::DropView(q) => out.push(q.name.clone()),
2483        QueryExpr::RefreshMaterializedView(q) => out.push(q.name.clone()),
2484
2485        // Vector / Hybrid / Graph / Path / commands reference
2486        // collections through fields whose shape varies; without a
2487        // uniform accessor we fall back to the global lock only —
2488        // benign because every runtime path still holds the global
2489        // mode.
2490        _ => {}
2491    }
2492}
2493
2494impl RedDBRuntime {
2495    pub fn in_memory() -> RedDBResult<Self> {
2496        Self::with_options(RedDBOptions::in_memory())
2497    }
2498
2499    pub fn flush(&self) -> RedDBResult<()> {
2500        self.inner
2501            .db
2502            .flush()
2503            .map_err(|err| RedDBError::Internal(err.to_string()))
2504    }
2505
2506    /// Handle to the intent-lock manager for tests + introspection.
2507    /// Production code acquires via `LockerGuard::new(rt.lock_manager())`
2508    /// rather than touching the manager directly.
2509    pub fn lock_manager(&self) -> std::sync::Arc<crate::storage::transaction::lock::LockManager> {
2510        self.inner.lock_manager.clone()
2511    }
2512
2513    /// Process-local governance registry for managed policy/config guardrails.
2514    pub fn config_registry(&self) -> std::sync::Arc<crate::auth::registry::ConfigRegistry> {
2515        self.inner.config_registry.clone()
2516    }
2517
2518    pub fn query_audit(&self) -> std::sync::Arc<crate::runtime::query_audit::QueryAuditStream> {
2519        self.inner.query_audit.clone()
2520    }
2521
2522    pub fn control_events_require_persistence(&self) -> bool {
2523        self.inner.control_event_config.require_persistence()
2524    }
2525
2526    pub fn control_event_config(&self) -> crate::runtime::control_events::ControlEventConfig {
2527        self.inner.control_event_config
2528    }
2529
2530    pub fn control_event_ledger(
2531        &self,
2532    ) -> Arc<dyn crate::runtime::control_events::ControlEventLedger> {
2533        self.inner.control_event_ledger.read().clone()
2534    }
2535
2536    #[doc(hidden)]
2537    pub fn replace_control_event_ledger_for_tests(
2538        &self,
2539        ledger: Arc<dyn crate::runtime::control_events::ControlEventLedger>,
2540    ) {
2541        *self.inner.control_event_ledger.write() = ledger;
2542    }
2543
2544    #[inline(never)]
2545    pub fn with_options(options: RedDBOptions) -> RedDBResult<Self> {
2546        Self::with_pool(options, ConnectionPoolConfig::default())
2547    }
2548
2549    pub fn with_pool(
2550        options: RedDBOptions,
2551        pool_config: ConnectionPoolConfig,
2552    ) -> RedDBResult<Self> {
2553        // PLAN.md Phase 9.1 — capture wall-clock before storage
2554        // open so the cold-start phase markers can be backfilled
2555        // once Lifecycle is constructed below. Storage open
2556        // encapsulates auto-restore + WAL replay; we treat the
2557        // whole window as one combined "restore" + "wal_replay"
2558        // phase split at the same boundary because the storage
2559        // layer doesn't yet emit a finer signal.
2560        let boot_open_start_ms = std::time::SystemTime::now()
2561            .duration_since(std::time::UNIX_EPOCH)
2562            .map(|d| d.as_millis() as u64)
2563            .unwrap_or(0);
2564        let embedded_single_file = options.storage_profile.deploy_profile
2565            == crate::storage::DeployProfile::Embedded
2566            && options.storage_profile.packaging == crate::storage::StoragePackaging::SingleFile;
2567        let db = Arc::new(
2568            RedDB::open_with_options(&options)
2569                .map_err(|err| RedDBError::Internal(err.to_string()))?,
2570        );
2571        let result_blob_cache_config = if embedded_single_file {
2572            crate::storage::cache::BlobCacheConfig::default()
2573        } else {
2574            crate::storage::cache::BlobCacheConfig::default().with_l2_path(
2575                reddb_file::layout::result_cache_l2_path(
2576                    &options.resolved_path(reddb_file::default_database_path()),
2577                ),
2578            )
2579        };
2580        let result_blob_cache =
2581            crate::storage::cache::BlobCache::open_with_l2(result_blob_cache_config).map_err(
2582                |err| RedDBError::Internal(format!("open result Blob Cache L2 failed: {err:?}")),
2583            )?;
2584        let storage_ready_ms = std::time::SystemTime::now()
2585            .duration_since(std::time::UNIX_EPOCH)
2586            .map(|d| d.as_millis() as u64)
2587            .unwrap_or(0);
2588
2589        let runtime = Self {
2590            inner: Arc::new(RuntimeInner {
2591                db: db.clone(),
2592                layout: PhysicalLayout::from_options(&options),
2593                embedded_single_file,
2594                indices: IndexCatalog::register_default_vector_graph(
2595                    options.has_capability(crate::api::Capability::Table),
2596                    options.has_capability(crate::api::Capability::Graph),
2597                ),
2598                pool_config,
2599                pool: Mutex::new(PoolState::default()),
2600                started_at_unix_ms: SystemTime::now()
2601                    .duration_since(UNIX_EPOCH)
2602                    .unwrap_or_default()
2603                    .as_millis(),
2604                probabilistic: super::probabilistic_store::ProbabilisticStore::new(),
2605                index_store: super::index_store::IndexStore::new(),
2606                cdc: crate::replication::cdc::CdcBuffer::new(100_000),
2607                backup_scheduler: crate::replication::scheduler::BackupScheduler::new(3600),
2608                query_cache: parking_lot::RwLock::new(
2609                    crate::storage::query::planner::cache::PlanCache::new(1000),
2610                ),
2611                result_cache: parking_lot::RwLock::new((
2612                    HashMap::new(),
2613                    std::collections::VecDeque::new(),
2614                )),
2615                result_blob_cache,
2616                result_blob_entries: parking_lot::RwLock::new((
2617                    HashMap::new(),
2618                    std::collections::VecDeque::new(),
2619                )),
2620                ask_answer_cache_entries: parking_lot::RwLock::new((
2621                    HashSet::new(),
2622                    std::collections::VecDeque::new(),
2623                )),
2624                result_cache_shadow_divergences: std::sync::atomic::AtomicU64::new(0),
2625                result_cache_hits: std::sync::atomic::AtomicU64::new(0),
2626                result_cache_misses: std::sync::atomic::AtomicU64::new(0),
2627                result_cache_evictions: std::sync::atomic::AtomicU64::new(0),
2628                ask_daily_spend: parking_lot::RwLock::new(HashMap::new()),
2629                queue_message_locks: parking_lot::RwLock::new(HashMap::new()),
2630                rmw_locks: RmwLockTable::new(),
2631                planner_dirty_tables: parking_lot::RwLock::new(HashSet::new()),
2632                ec_registry: Arc::new(crate::ec::config::EcRegistry::new()),
2633                config_registry: Arc::new(crate::auth::registry::ConfigRegistry::new()),
2634                ec_worker: crate::ec::worker::EcWorker::new(),
2635                auth_store: parking_lot::RwLock::new(None),
2636                oauth_validator: parking_lot::RwLock::new(None),
2637                browser_token_authority: parking_lot::RwLock::new(None),
2638                views: parking_lot::RwLock::new(HashMap::new()),
2639                materialized_views: parking_lot::RwLock::new(
2640                    crate::storage::cache::result::MaterializedViewCache::new(),
2641                ),
2642                retention_sweeper: parking_lot::RwLock::new(
2643                    crate::runtime::retention_sweeper::RetentionSweeperState::new(),
2644                ),
2645                snapshot_manager: Arc::new(
2646                    crate::storage::transaction::snapshot::SnapshotManager::new(),
2647                ),
2648                tx_contexts: parking_lot::RwLock::new(HashMap::new()),
2649                tx_local_tenants: parking_lot::RwLock::new(HashMap::new()),
2650                env_config_overrides: crate::runtime::config_overlay::collect_env_overrides(),
2651                lock_manager: Arc::new({
2652                    // Sourced from the matrix: Tier B key
2653                    // `concurrency.locking.deadlock_timeout_ms`
2654                    // (default 5000). Env var wins at boot so
2655                    // operators can tune without touching red_config.
2656                    let env = crate::runtime::config_overlay::collect_env_overrides();
2657                    let timeout_ms = env
2658                        .get("concurrency.locking.deadlock_timeout_ms")
2659                        .and_then(|raw| raw.parse::<u64>().ok())
2660                        .unwrap_or_else(|| {
2661                            match crate::runtime::config_matrix::default_for(
2662                                "concurrency.locking.deadlock_timeout_ms",
2663                            ) {
2664                                Some(crate::serde_json::Value::Number(n)) => n as u64,
2665                                _ => 5000,
2666                            }
2667                        });
2668                    let cfg = crate::storage::transaction::lock::LockConfig {
2669                        default_timeout: std::time::Duration::from_millis(timeout_ms),
2670                        ..Default::default()
2671                    };
2672                    crate::storage::transaction::lock::LockManager::new(cfg)
2673                }),
2674                rls_policies: parking_lot::RwLock::new(HashMap::new()),
2675                rls_enabled_tables: parking_lot::RwLock::new(HashSet::new()),
2676                foreign_tables: Arc::new(crate::storage::fdw::ForeignTableRegistry::with_builtins()),
2677                pending_tombstones: parking_lot::RwLock::new(HashMap::new()),
2678                pending_versioned_updates: parking_lot::RwLock::new(HashMap::new()),
2679                pending_kv_watch_events: parking_lot::RwLock::new(HashMap::new()),
2680                pending_store_wal_actions: parking_lot::RwLock::new(HashMap::new()),
2681                pending_claim_locks: parking_lot::RwLock::new(HashMap::new()),
2682                queue_wait_registry: std::sync::Arc::new(
2683                    crate::runtime::queue_wait_registry::QueueWaitRegistry::new(),
2684                ),
2685                pending_queue_wakes: parking_lot::RwLock::new(HashMap::new()),
2686                tenant_tables: parking_lot::RwLock::new(HashMap::new()),
2687                ddl_epoch: std::sync::atomic::AtomicU64::new(0),
2688                write_gate: Arc::new(crate::runtime::write_gate::WriteGate::from_options(
2689                    &options,
2690                )),
2691                lifecycle: crate::runtime::lifecycle::Lifecycle::new(),
2692                resource_limits: crate::runtime::resource_limits::ResourceLimits::from_env(),
2693                audit_log: {
2694                    // Default audit-log path for the in-memory case
2695                    // sits in the system temp dir; persistent runs
2696                    // place it next to the resolved data file.
2697                    //
2698                    // gh-471 iter 2: route through the resolved
2699                    // `LogDestination`. Performance/Max tiers emit a
2700                    // file-backed log destination under the file-owned
2701                    // support-directory logs tier;
2702                    // lower tiers / ephemeral runs report `Stderr`
2703                    // and we keep the legacy file-next-to-data sink.
2704                    // #1375 — single-file embedded mode keeps the data
2705                    // directory to exactly the `.rdb` artifact, so the audit
2706                    // log must NOT land as a sibling. Route it to a
2707                    // process-unique temp location even when a data path is
2708                    // set; only the non-embedded case uses the data dir.
2709                    let data_path = if embedded_single_file {
2710                        std::env::temp_dir()
2711                            .join("reddb-embedded-runtime")
2712                            .join(format!("audit-{}", std::process::id()))
2713                    } else {
2714                        options
2715                            .data_path
2716                            .clone()
2717                            .unwrap_or_else(|| std::env::temp_dir().join("reddb"))
2718                    };
2719                    let (audit_dest, _) = crate::api::tier_wiring::current_log_destinations();
2720                    if !matches!(audit_dest, crate::storage::layout::LogDestination::File(_))
2721                        && (embedded_single_file
2722                            || options
2723                                .metadata
2724                                .contains_key(crate::api::EPHEMERAL_RUNTIME_METADATA_KEY))
2725                    {
2726                        // The Stderr/Syslog lower-tier sink resolves to a
2727                        // `for_data_path` sibling that collides across concurrent
2728                        // temp-dir runtimes — nextest's process-per-test model
2729                        // truncates one shared file, flaking audit assertions.
2730                        // Pin a unique sibling for these short-lived ephemeral /
2731                        // single-file embedded runtimes. The file-owned support-
2732                        // dir tier (`File`) is already per-data unique, so leave
2733                        // it to `for_destination` (#1375: the embedded audit then
2734                        // still never lands a sibling next to the `.rdb`).
2735                        let audit_path = reddb_file::layout::sibling_path(
2736                            &data_path,
2737                            &reddb_file::layout::sidecar_file_name(&data_path, "audit.log"),
2738                        );
2739                        Arc::new(crate::runtime::audit_log::AuditLogger::with_path(
2740                            audit_path,
2741                        ))
2742                    } else {
2743                        Arc::new(crate::runtime::audit_log::AuditLogger::for_destination(
2744                            &audit_dest,
2745                            &data_path,
2746                        ))
2747                    }
2748                },
2749                control_event_ledger: parking_lot::RwLock::new(Arc::new(
2750                    crate::runtime::control_events::RuntimeLedger::new(db.store()),
2751                )),
2752                control_event_config: options.control_events,
2753                query_audit: Arc::new(crate::runtime::query_audit::QueryAuditStream::new(
2754                    db.store(),
2755                    options.query_audit.clone(),
2756                )),
2757                lease_lifecycle: std::sync::OnceLock::new(),
2758                replica_apply_metrics: std::sync::Arc::new(
2759                    crate::replication::logical::ReplicaApplyMetrics::default(),
2760                ),
2761                replica_link_metrics: std::sync::Arc::new(
2762                    crate::replication::reconnect::ReplicaLinkMetrics::default(),
2763                ),
2764                quota_bucket: crate::runtime::quota_bucket::QuotaBucket::from_env(),
2765                schema_vocabulary: parking_lot::RwLock::new(
2766                    crate::runtime::schema_vocabulary::SchemaVocabulary::new(),
2767                ),
2768                slow_query_logger: {
2769                    // Issue #205 — slow-query sink lives in the same
2770                    // directory the audit log uses, so backup/restore
2771                    // ships them together. Threshold + sample-pct
2772                    // default conservatively (1 s, 100% sampling) so
2773                    // emitted lines are rare and complete. Operators
2774                    // tune via env / config matrix in a follow-up.
2775                    //
2776                    // gh-471 iter 2: same routing as the audit log —
2777                    // `LogDestination::File(...)` for Performance/Max
2778                    // lands under the file-owned support-directory logs tier;
2779                    // lower tiers fall back to `red-slow.log` in the
2780                    // data directory.
2781                    // #1375 — see the audit-log note above: single-file mode
2782                    // never writes the slow-query log as a sibling of the
2783                    // `.rdb`. Route to a process-unique temp dir when embedded,
2784                    // regardless of the data path.
2785                    let fallback_dir = if embedded_single_file {
2786                        std::env::temp_dir()
2787                            .join("reddb-embedded-runtime")
2788                            .join(format!("slow-{}", std::process::id()))
2789                    } else {
2790                        options
2791                            .data_path
2792                            .as_ref()
2793                            .and_then(|p| p.parent().map(std::path::PathBuf::from))
2794                            .unwrap_or_else(|| std::env::temp_dir().join("reddb"))
2795                    };
2796                    let threshold_ms = std::env::var("RED_SLOW_QUERY_THRESHOLD_MS")
2797                        .ok()
2798                        .and_then(|s| s.parse::<u64>().ok())
2799                        .unwrap_or(1000);
2800                    let sample_pct = std::env::var("RED_SLOW_QUERY_SAMPLE_PCT")
2801                        .ok()
2802                        .and_then(|s| s.parse::<u8>().ok())
2803                        .unwrap_or(100);
2804                    let (_, slow_dest) = crate::api::tier_wiring::current_log_destinations();
2805                    crate::telemetry::slow_query_logger::SlowQueryLogger::for_destination(
2806                        &slow_dest,
2807                        &fallback_dir,
2808                        threshold_ms,
2809                        sample_pct,
2810                    )
2811                },
2812                slow_query_store: crate::telemetry::slow_query_store::SlowQueryStore::new(
2813                    crate::telemetry::slow_query_store::DEFAULT_CAP,
2814                ),
2815                kv_stats: crate::runtime::KvStatsCounters::default(),
2816                metrics_ingest_stats: crate::runtime::MetricsIngestCounters::default(),
2817                metrics_tenant_activity_stats:
2818                    crate::runtime::MetricsTenantActivityCounters::default(),
2819                claim_telemetry: Arc::new(
2820                    crate::runtime::claim_telemetry::ClaimTelemetryCounters::default(),
2821                ),
2822                queue_telemetry: Arc::new(
2823                    crate::runtime::queue_telemetry::QueueTelemetryCounters::default(),
2824                ),
2825                query_latency_telemetry: Arc::new(
2826                    crate::runtime::query_latency_telemetry::QueryLatencyTelemetry::default(),
2827                ),
2828                occupancy_sampler: Arc::new(
2829                    crate::runtime::occupancy_sampler::OccupancySampler::new(),
2830                ),
2831                node_load_telemetry: Arc::new(
2832                    crate::runtime::node_load_telemetry::NodeLoadTelemetry::default(),
2833                ),
2834                queue_presence: Arc::new(
2835                    crate::storage::queue::presence::ConsumerPresenceRegistry::new(),
2836                ),
2837                vector_introspection: Arc::new(
2838                    crate::storage::vector::introspection::VectorIntrospectionRegistry::new(),
2839                ),
2840                kv_tag_index: crate::runtime::KvTagIndex::default(),
2841                chain_tip_cache: parking_lot::Mutex::new(HashMap::new()),
2842                chain_integrity_broken: parking_lot::Mutex::new(HashMap::new()),
2843                integrity_tombstones: parking_lot::Mutex::new(Vec::new()),
2844                integrity_tombstones_state: std::sync::atomic::AtomicU8::new(0),
2845            }),
2846        };
2847
2848        // Issue #205 — install the process-wide OperatorEvent sink so
2849        // emit sites buried in storage / replication / signal handlers
2850        // can record without threading an `&AuditLogger` through every
2851        // call stack. First registration wins; subsequent in-memory
2852        // runtimes (test harnesses) fall through to tracing+eprintln.
2853        crate::telemetry::operator_event::install_global_audit_sink(Arc::clone(
2854            &runtime.inner.audit_log,
2855        ));
2856
2857        // Issue #1238 — wire the slow-query telemetry substrate (ADR 0060).
2858        // The logger dual-writes: file sink (existing) + ring store (new).
2859        runtime
2860            .inner
2861            .slow_query_logger
2862            .attach_store(Arc::clone(&runtime.inner.slow_query_store));
2863
2864        // PLAN.md Phase 9.1 — backfill cold-start phase markers
2865        // from the wall-clock captured before storage open. The
2866        // entire `RedDB::open_with_options` call covers both
2867        // auto-restore (when configured) and WAL replay. We
2868        // record both phases against the same boundary today;
2869        // a follow-up will split them once the storage layer
2870        // surfaces a finer-grained event.
2871        runtime
2872            .inner
2873            .lifecycle
2874            .set_restore_started_at_ms(boot_open_start_ms);
2875        runtime
2876            .inner
2877            .lifecycle
2878            .set_restore_ready_at_ms(storage_ready_ms);
2879        runtime
2880            .inner
2881            .lifecycle
2882            .set_wal_replay_started_at_ms(boot_open_start_ms);
2883        runtime
2884            .inner
2885            .lifecycle
2886            .set_wal_replay_ready_at_ms(storage_ready_ms);
2887
2888        let restored_cdc_lsn = runtime
2889            .inner
2890            .db
2891            .replication
2892            .as_ref()
2893            .map(|repl| {
2894                repl.logical_wal_spool
2895                    .as_ref()
2896                    .map(|spool| spool.current_lsn())
2897                    .unwrap_or(0)
2898            })
2899            .unwrap_or(0)
2900            .max(runtime.config_u64("red.config.timeline.last_archived_lsn", 0));
2901        runtime.inner.cdc.set_current_lsn(restored_cdc_lsn);
2902        runtime.rehydrate_snapshot_xid_floor();
2903        runtime
2904            .bootstrap_system_keyed_collections()
2905            .map_err(|err| RedDBError::Internal(format!("bootstrap system collections: {err}")))?;
2906        runtime.rehydrate_declared_column_schemas();
2907        runtime.rehydrate_runtime_index_registry()?;
2908        runtime
2909            .load_probabilistic_state()
2910            .map_err(|err| RedDBError::Internal(format!("load probabilistic state: {err}")))?;
2911
2912        // Phase 2.5.4: replay `tenant_tables.{table}.column` markers so
2913        // tables declared via `TENANT BY (col)` survive restart. Each
2914        // entry re-registers the auto-policy and flips RLS on again.
2915        runtime.rehydrate_tenant_tables();
2916        // Issue #593 slice 9a — replay persisted materialized-view
2917        // descriptors so `CREATE MATERIALIZED VIEW v AS …` survives a
2918        // restart. Runs after the system-keyed collections bootstrap
2919        // and before the API opens.
2920        runtime.rehydrate_materialized_view_descriptors();
2921        if let Some(repl) = &runtime.inner.db.replication {
2922            repl.wal_buffer.set_current_lsn(restored_cdc_lsn);
2923        }
2924
2925        // Save system info to red_config on boot
2926        {
2927            let sys = SystemInfo::collect();
2928            runtime.inner.db.store().set_config_tree(
2929                "red.system",
2930                &crate::serde_json::json!({
2931                    "pid": sys.pid,
2932                    "cpu_cores": sys.cpu_cores,
2933                    "total_memory_bytes": sys.total_memory_bytes,
2934                    "available_memory_bytes": sys.available_memory_bytes,
2935                    "os": sys.os,
2936                    "arch": sys.arch,
2937                    "hostname": sys.hostname,
2938                    "started_at": SystemTime::now()
2939                        .duration_since(UNIX_EPOCH)
2940                        .unwrap_or_default()
2941                        .as_millis() as u64
2942                }),
2943            );
2944
2945            // Seed defaults on first boot (only if red_config is empty or missing defaults)
2946            let store = runtime.inner.db.store();
2947            if store
2948                .get_collection("red_config")
2949                .map(|m| m.query_all(|_| true).len())
2950                .unwrap_or(0)
2951                <= 10
2952            {
2953                store.set_config_tree("red.ai", &crate::json!({
2954                    "default": crate::json!({
2955                        "provider": "openai",
2956                        "model": crate::ai::DEFAULT_OPENAI_PROMPT_MODEL
2957                    }),
2958                    "max_embedding_inputs": 256,
2959                    "max_prompt_batch": 256,
2960                    "timeout": crate::json!({ "connect_secs": 10, "read_secs": 90, "write_secs": 30 })
2961                }));
2962                store.set_config_tree(
2963                    "red.server",
2964                    &crate::json!({
2965                        "max_scan_limit": 1000,
2966                        "max_body_size": 1048576,
2967                        "read_timeout_ms": 5000,
2968                        "write_timeout_ms": 5000
2969                    }),
2970                );
2971                store.set_config_tree(
2972                    "red.storage",
2973                    &crate::json!({
2974                        "page_size": 4096,
2975                        "page_cache_capacity": 100000,
2976                        "auto_checkpoint_pages": 1000,
2977                        "snapshot_retention": 16,
2978                        "verify_checksums": true,
2979                        "segment": crate::json!({
2980                            "max_entities": 100000,
2981                            "max_bytes": 268435456_u64,
2982                            "compression_level": 6
2983                        }),
2984                        "hnsw": crate::json!({ "m": 16, "ef_construction": 100, "ef_search": 50 }),
2985                        "ivf": crate::json!({ "n_lists": 100, "n_probes": 10 }),
2986                        "bm25": crate::json!({ "k1": 1.2, "b": 0.75 })
2987                    }),
2988                );
2989                store.set_config_tree(
2990                    "red.search",
2991                    &crate::json!({
2992                        "rag": crate::json!({
2993                            "max_chunks_per_source": 10,
2994                            "max_total_chunks": 25,
2995                            "similarity_threshold": 0.8,
2996                            "graph_depth": 2,
2997                            "min_relevance": 0.3
2998                        }),
2999                        "fusion": crate::json!({
3000                            "vector_weight": 0.5,
3001                            "graph_weight": 0.3,
3002                            "table_weight": 0.2,
3003                            "dedup_threshold": 0.85
3004                        })
3005                    }),
3006                );
3007                store.set_config_tree(
3008                    "red.auth",
3009                    &crate::json!({
3010                        "enabled": false,
3011                        "session_ttl_secs": 3600,
3012                        "require_auth": false
3013                    }),
3014                );
3015                store.set_config_tree(
3016                    "red.query",
3017                    &crate::json!({
3018                        "connection_pool": crate::json!({ "max_connections": 64, "max_idle": 16 }),
3019                        "max_recursion_depth": 1000
3020                    }),
3021                );
3022                store.set_config_tree(
3023                    "red.indexes",
3024                    &crate::json!({
3025                        "auto_select": true,
3026                        "bloom_filter": crate::json!({
3027                            "enabled": true,
3028                            "false_positive_rate": 0.01,
3029                            "prune_on_scan": true
3030                        }),
3031                        "hash": crate::json!({ "enabled": true }),
3032                        "bitmap": crate::json!({ "enabled": true, "max_cardinality": 1000 }),
3033                        "spatial": crate::json!({ "enabled": true })
3034                    }),
3035                );
3036                store.set_config_tree(
3037                    "red.memtable",
3038                    &crate::json!({
3039                        "enabled": true,
3040                        "max_bytes": 67108864_u64,
3041                        "flush_threshold": 0.75
3042                    }),
3043                );
3044                store.set_config_tree(
3045                    "red.probabilistic",
3046                    &crate::json!({
3047                        "hll_registers": 16384,
3048                        "sketch_default_width": 1000,
3049                        "sketch_default_depth": 5,
3050                        "filter_default_capacity": 100000
3051                    }),
3052                );
3053                store.set_config_tree(
3054                    "red.timeseries",
3055                    &crate::json!({
3056                        "default_chunk_size": 1024,
3057                        "compression": crate::json!({
3058                            "timestamps": "delta_of_delta",
3059                            "values": "gorilla_xor"
3060                        }),
3061                        "default_retention_days": 0
3062                    }),
3063                );
3064                store.set_config_tree(
3065                    "red.queue",
3066                    &crate::json!({
3067                        "default_max_size": 0,
3068                        "default_max_attempts": 3,
3069                        "visibility_timeout_ms": 30000,
3070                        "consumer_idle_timeout_ms": 60000
3071                    }),
3072                );
3073                store.set_config_tree(
3074                    "red.backup",
3075                    &crate::json!({
3076                        "enabled": false,
3077                        "interval_secs": 3600,
3078                        "retention_count": 24,
3079                        "upload": false,
3080                        "backend": "local"
3081                    }),
3082                );
3083                store.set_config_tree(
3084                    "red.wal",
3085                    &crate::json!({
3086                        "archive": crate::json!({
3087                            "enabled": false,
3088                            "retention_hours": 168,
3089                            "prefix": reddb_file::backup_wal_prefix("")
3090                        })
3091                    }),
3092                );
3093                store.set_config_tree(
3094                    "red.cdc",
3095                    &crate::json!({
3096                        "enabled": true,
3097                        "buffer_size": 100000
3098                    }),
3099                );
3100                store.set_config_tree(
3101                    "red.config.secret",
3102                    &crate::json!({
3103                        "auto_encrypt": true,
3104                        "auto_decrypt": true
3105                    }),
3106                );
3107            }
3108
3109            // Perf-parity config matrix: heal the Tier A (critical)
3110            // keys unconditionally on every boot. Idempotent — only
3111            // writes the default when the key is missing. Keeps
3112            // `SHOW CONFIG` showing every guarantee the operator has
3113            // (durability.mode, concurrency.locking.enabled, …) even
3114            // on long-running datadirs that predate the matrix.
3115            crate::runtime::config_matrix::heal_critical_keys(store.as_ref());
3116            seed_storage_deploy_config(store.as_ref(), options.storage_profile);
3117
3118            // Phase 5 — Lehman-Yao runtime flag. Read the Tier A
3119            // `storage.btree.lehman_yao` value from the matrix (env
3120            // > file > red_config > default) and publish it to the
3121            // storage layer's atomic so the B-tree read / split
3122            // paths can branch without re-reading the config on
3123            // every hot-path call.
3124            let lehman_yao = runtime.config_bool("storage.btree.lehman_yao", true);
3125            crate::storage::engine::btree::lehman_yao::set_enabled(lehman_yao);
3126            if lehman_yao {
3127                tracing::info!(
3128                    "storage.btree.lehman_yao=true — lock-free concurrent descent enabled"
3129                );
3130            }
3131
3132            // Config file overlay — mounted `/etc/reddb/config.json`
3133            // (override path via REDDB_CONFIG_FILE). Writes keys with
3134            // write-if-absent semantics so a later user `SET CONFIG`
3135            // always wins. Missing file = silent no-op.
3136            let overlay_path = crate::runtime::config_overlay::config_file_path();
3137            let _ =
3138                crate::runtime::config_overlay::apply_config_file(store.as_ref(), &overlay_path);
3139        }
3140
3141        // VCS ("Git for Data") — create the `red_*` metadata
3142        // collections on first boot. Idempotent: `get_or_create_collection`
3143        // is a no-op if the collection already exists.
3144        {
3145            let store = runtime.inner.db.store();
3146            for name in crate::application::vcs_collections::ALL {
3147                let _ = store.get_or_create_collection(*name);
3148            }
3149            // Seed VCS config namespace with sensible defaults on first
3150            // boot, matching the pattern used by red.ai / red.storage.
3151            store.set_config_tree(
3152                crate::application::vcs_collections::CONFIG_NAMESPACE,
3153                &crate::json!({
3154                    "default_branch": "main",
3155                    "author": crate::json!({
3156                        "name": "reddb",
3157                        "email": "reddb@localhost"
3158                    }),
3159                    "protected_branches": crate::json!(["main"]),
3160                    "closure": crate::json!({
3161                        "enabled": true,
3162                        "lazy": true
3163                    }),
3164                    "merge": crate::json!({
3165                        "default_strategy": "auto",
3166                        "fast_forward": true
3167                    })
3168                }),
3169            );
3170        }
3171
3172        // Migrations — create the `red_migrations` / `red_migration_deps`
3173        // system collections on first boot. Idempotent.
3174        {
3175            let store = runtime.inner.db.store();
3176            for name in crate::application::migration_collections::ALL {
3177                let _ = store.get_or_create_collection(*name);
3178            }
3179        }
3180
3181        // Topology graph (#803) — ensure the built-in `red.topology.cluster`
3182        // graph collection (declared WITH ANALYTICS) and its metadata sidecar
3183        // exist. Idempotent and survives restarts via the WAL-backed contract.
3184        let _ = crate::application::topology_collections::ensure(&runtime);
3185
3186        // #1369 — reserve a fixed internal-id floor so the first user-inserted
3187        // entity always receives a stable, documented `rid` (FIRST_USER_ENTITY_ID),
3188        // independent of how many internal collection-descriptor / config-default
3189        // entities the boot sequence seeded above. `register_entity_id` only ever
3190        // raises the allocator, so a database that already holds user data
3191        // (counter past the floor) is untouched; a freshly-seeded database jumps
3192        // straight to the floor.
3193        runtime
3194            .inner
3195            .db
3196            .store()
3197            .register_entity_id(crate::storage::EntityId::new(
3198                crate::storage::FIRST_USER_ENTITY_ID - 1,
3199            ));
3200
3201        // Start background maintenance thread (context index refresh +
3202        // session purge). Held by a WEAK reference to `RuntimeInner`
3203        // so dropping the last `RedDBRuntime` handle actually releases
3204        // the underlying Arc<Pager> (and its file lock). Polling at
3205        // 200ms means shutdown latency is bounded; the real 60-second
3206        // work cadence is tracked independently via a `last_work`
3207        // timestamp.
3208        //
3209        // The previous version captured `rt = runtime.clone()` by
3210        // strong reference and ran an unterminated `loop`, which held
3211        // Arc<RuntimeInner> forever — reopening a persistent database
3212        // in the same process failed with "Database is locked" because
3213        // the pager could never drop. See the regression test
3214        // `finding_1_select_after_bulk_insert_persistent_reopen`.
3215        {
3216            let weak = Arc::downgrade(&runtime.inner);
3217            std::thread::Builder::new()
3218                .name("reddb-maintenance".into())
3219                .spawn(move || {
3220                    let tick = std::time::Duration::from_millis(200);
3221                    let work_interval = std::time::Duration::from_secs(60);
3222                    let mut last_work = std::time::Instant::now();
3223                    loop {
3224                        std::thread::sleep(tick);
3225                        let Some(inner) = weak.upgrade() else {
3226                            // All strong references dropped — the
3227                            // runtime is gone, exit cleanly.
3228                            break;
3229                        };
3230                        if last_work.elapsed() >= work_interval {
3231                            let _stats = inner.db.store().context_index().stats();
3232                            last_work = std::time::Instant::now();
3233                        }
3234                    }
3235                })
3236                .ok();
3237        }
3238
3239        // Start backup scheduler if enabled via red_config
3240        {
3241            let store = runtime.inner.db.store();
3242            let mut backup_enabled = false;
3243            let mut backup_interval = 3600u64;
3244
3245            if let Some(manager) = store.get_collection("red_config") {
3246                manager.for_each_entity(|entity| {
3247                    if let Some(row) = entity.data.as_row() {
3248                        let key = row.get_field("key").and_then(|v| match v {
3249                            crate::storage::schema::Value::Text(s) => Some(s.as_ref()),
3250                            _ => None,
3251                        });
3252                        let val = row.get_field("value");
3253                        if key == Some("red.config.backup.enabled") {
3254                            backup_enabled = match val {
3255                                Some(crate::storage::schema::Value::Boolean(true)) => true,
3256                                Some(crate::storage::schema::Value::Text(s)) => &**s == "true",
3257                                _ => false,
3258                            };
3259                        } else if key == Some("red.config.backup.interval_secs") {
3260                            if let Some(crate::storage::schema::Value::Integer(n)) = val {
3261                                backup_interval = *n as u64;
3262                            }
3263                        }
3264                    }
3265                    true
3266                });
3267            }
3268
3269            if backup_enabled {
3270                runtime.inner.backup_scheduler.set_interval(backup_interval);
3271                let rt = runtime.clone();
3272                runtime
3273                    .inner
3274                    .backup_scheduler
3275                    .start(move || rt.trigger_backup().map_err(|e| format!("{}", e)));
3276            }
3277        }
3278
3279        // Load EC registry from red_config and start worker
3280        {
3281            runtime
3282                .inner
3283                .ec_registry
3284                .load_from_config_store(runtime.inner.db.store().as_ref());
3285            if !runtime.inner.ec_registry.async_configs().is_empty() {
3286                runtime.inner.ec_worker.start(
3287                    Arc::clone(&runtime.inner.ec_registry),
3288                    Arc::clone(&runtime.inner.db.store()),
3289                );
3290            }
3291        }
3292
3293        if let crate::replication::ReplicationRole::Replica { primary_addr } =
3294            runtime.inner.db.options().replication.role.clone()
3295        {
3296            let rt = runtime.clone();
3297            std::thread::Builder::new()
3298                .name("reddb-replica".into())
3299                .spawn(move || rt.run_replica_loop(primary_addr))
3300                .ok();
3301        }
3302
3303        // PLAN.md Phase 1 — Lifecycle Contract. Mark Ready once every
3304        // boot stage above has completed (WAL replay, restore-from-
3305        // remote, replica-loop spawn). Health probes flip from 503 to
3306        // 200 here; shutdown begins from this state.
3307        runtime.inner.lifecycle.mark_ready();
3308
3309        // Issue #583 slice 10 — ContinuousMaterializedView scheduler.
3310        // Low-priority background ticker that drains the cache's
3311        // `claim_due_at` set every ~50ms. Holds only a Weak<RuntimeInner>
3312        // so the thread exits cleanly when the runtime drops (≤50ms
3313        // latency between drop and exit). Materialized views without
3314        // a `REFRESH EVERY` clause stay on the manual-refresh path
3315        // and are skipped by `claim_due_at`, so the loop is a no-op
3316        // when no scheduled views exist.
3317        {
3318            let weak_inner = Arc::downgrade(&runtime.inner);
3319            std::thread::Builder::new()
3320                .name("reddb-mv-scheduler".into())
3321                .spawn(move || loop {
3322                    std::thread::sleep(std::time::Duration::from_millis(50));
3323                    let Some(inner) = weak_inner.upgrade() else {
3324                        break;
3325                    };
3326                    let rt = RedDBRuntime { inner };
3327                    rt.refresh_due_materialized_views();
3328                })
3329                .ok();
3330        }
3331
3332        // Issue #584 slice 12 — DeclarativeRetention background sweeper.
3333        // Low-priority ticker that physically reclaims rows whose
3334        // timestamp has fallen beyond the retention window. Holds a
3335        // `Weak<RuntimeInner>` so the thread exits within one tick of
3336        // the runtime drop (graceful shutdown leaves storage consistent
3337        // because each tick goes through the standard DELETE path —
3338        // there is no half-finished mutation state to clean up). The
3339        // tick interval is intentionally longer than the MV scheduler
3340        // (500ms) because retention is order-of-seconds at minimum.
3341        if !runtime.write_gate().is_read_only() {
3342            let weak_inner = Arc::downgrade(&runtime.inner);
3343            std::thread::Builder::new()
3344                .name("reddb-retention-sweeper".into())
3345                .spawn(move || loop {
3346                    std::thread::sleep(std::time::Duration::from_millis(500));
3347                    let Some(inner) = weak_inner.upgrade() else {
3348                        break;
3349                    };
3350                    let rt = RedDBRuntime { inner };
3351                    rt.sweep_retention_tick(
3352                        crate::runtime::retention_sweeper::DEFAULT_SWEEPER_BATCH,
3353                    );
3354                })
3355                .ok();
3356        }
3357
3358        Ok(runtime)
3359    }
3360
3361    fn rehydrate_snapshot_xid_floor(&self) {
3362        let store = self.inner.db.store();
3363        for collection in store.list_collections() {
3364            let Some(manager) = store.get_collection(&collection) else {
3365                continue;
3366            };
3367            for entity in manager.query_all(|_| true) {
3368                self.inner
3369                    .snapshot_manager
3370                    .observe_committed_xid(entity.xmin);
3371                self.inner
3372                    .snapshot_manager
3373                    .observe_committed_xid(entity.xmax);
3374            }
3375        }
3376    }
3377
3378    /// Provision an empty Table-shaped collection that backs a
3379    /// `CREATE MATERIALIZED VIEW v` (issue #594 slice 9b of #575).
3380    /// `SELECT FROM v` reads this collection directly; the rewriter is
3381    /// configured to skip materialized views so the body is no longer
3382    /// substituted. REFRESH still writes to the cache slot — wiring it
3383    /// into this backing collection is the job of slice 9c.
3384    ///
3385    /// Idempotent: re-running for the same name leaves the existing
3386    /// collection in place (mirrors `CREATE TABLE IF NOT EXISTS`
3387    /// semantics). This keeps `CREATE OR REPLACE MATERIALIZED VIEW v`
3388    /// cheap — the body change does not invalidate already-buffered
3389    /// rows. Until 9c lands the backing is always empty anyway.
3390    pub(crate) fn ensure_materialized_view_backing(&self, name: &str) -> RedDBResult<()> {
3391        let store = self.inner.db.store();
3392        let mut changed = false;
3393        if store.get_collection(name).is_none() {
3394            store.get_or_create_collection(name);
3395            changed = true;
3396        }
3397        if self.inner.db.collection_contract(name).is_none() {
3398            self.inner
3399                .db
3400                .save_collection_contract(system_keyed_collection_contract(
3401                    name,
3402                    crate::catalog::CollectionModel::Table,
3403                ))
3404                .map_err(|err| RedDBError::Internal(err.to_string()))?;
3405            changed = true;
3406        }
3407        if changed {
3408            self.inner
3409                .db
3410                .persist_metadata()
3411                .map_err(|err| RedDBError::Internal(err.to_string()))?;
3412        }
3413        Ok(())
3414    }
3415
3416    /// Inverse of [`ensure_materialized_view_backing`] — drops the
3417    /// backing collection on `DROP MATERIALIZED VIEW v`. No-op when
3418    /// the collection was never created (e.g. a `DROP MATERIALIZED
3419    /// VIEW IF EXISTS v` against an unknown name).
3420    pub(crate) fn drop_materialized_view_backing(&self, name: &str) -> RedDBResult<()> {
3421        let store = self.inner.db.store();
3422        if store.get_collection(name).is_none() {
3423            return Ok(());
3424        }
3425        store
3426            .drop_collection(name)
3427            .map_err(|err| RedDBError::Internal(err.to_string()))?;
3428        // The contract may have been dropped already (DROP TABLE path)
3429        // — ignore "not found" errors by checking presence first.
3430        if self.inner.db.collection_contract(name).is_some() {
3431            self.inner
3432                .db
3433                .remove_collection_contract(name)
3434                .map_err(|err| RedDBError::Internal(err.to_string()))?;
3435        }
3436        self.invalidate_result_cache();
3437        self.inner
3438            .db
3439            .persist_metadata()
3440            .map_err(|err| RedDBError::Internal(err.to_string()))?;
3441        Ok(())
3442    }
3443
3444    fn bootstrap_system_keyed_collections(&self) -> RedDBResult<()> {
3445        let mut changed = false;
3446        for (name, model) in [
3447            ("red.config", crate::catalog::CollectionModel::Config),
3448            ("red.vault", crate::catalog::CollectionModel::Vault),
3449            // Issue #593 — materialized-view catalog. One row per
3450            // `CREATE MATERIALIZED VIEW`; rehydrated at boot before
3451            // the API opens.
3452            (
3453                crate::runtime::continuous_materialized_view::CATALOG_COLLECTION,
3454                crate::catalog::CollectionModel::Config,
3455            ),
3456        ] {
3457            if self.inner.db.store().get_collection(name).is_none() {
3458                self.inner.db.store().get_or_create_collection(name);
3459                changed = true;
3460            }
3461            if self.inner.db.collection_contract(name).is_none() {
3462                self.inner
3463                    .db
3464                    .save_collection_contract(system_keyed_collection_contract(name, model))
3465                    .map_err(|err| RedDBError::Internal(err.to_string()))?;
3466                changed = true;
3467            }
3468        }
3469        if changed {
3470            self.inner
3471                .db
3472                .persist_metadata()
3473                .map_err(|err| RedDBError::Internal(err.to_string()))?;
3474        }
3475        Ok(())
3476    }
3477
3478    pub fn db(&self) -> Arc<RedDB> {
3479        Arc::clone(&self.inner.db)
3480    }
3481
3482    /// Direct access to the runtime's secondary-index store.
3483    /// Used by bulk-insert entry points (gRPC binary bulk, HTTP bulk,
3484    /// wire bulk) that need to push new rows through the per-index
3485    /// maintenance hook after `store.bulk_insert` returns.
3486    pub fn index_store_ref(&self) -> &super::index_store::IndexStore {
3487        &self.inner.index_store
3488    }
3489
3490    /// Apply a DDL event to the schema-vocabulary reverse index
3491    /// (issue #120). Called by DDL execution paths after the catalog
3492    /// mutation has succeeded so the index never holds entries for
3493    /// half-applied DDL.
3494    pub(crate) fn schema_vocabulary_apply(
3495        &self,
3496        event: crate::runtime::schema_vocabulary::DdlEvent,
3497    ) {
3498        self.inner.schema_vocabulary.write().on_ddl(event);
3499    }
3500
3501    /// Lookup `token` in the schema-vocabulary reverse index. Returns
3502    /// an owned `Vec<VocabHit>` because the underlying read lock
3503    /// cannot be borrowed across the call boundary; the slice from
3504    /// `SchemaVocabulary::lookup` is cloned per hit.
3505    pub fn schema_vocabulary_lookup(
3506        &self,
3507        token: &str,
3508    ) -> Vec<crate::runtime::schema_vocabulary::VocabHit> {
3509        self.inner.schema_vocabulary.read().lookup(token).to_vec()
3510    }
3511
3512    /// Inject an AuthStore into the runtime. Called by server boot
3513    /// after the vault has been bootstrapped, so that `Value::Secret`
3514    /// auto-encrypt/decrypt can reach the vault AES key.
3515    pub fn set_auth_store(&self, store: Arc<crate::auth::store::AuthStore>) {
3516        *self.inner.auth_store.write() = Some(store);
3517    }
3518
3519    /// Snapshot the current AuthStore (if any). Used by the wire listener
3520    /// to validate bearer tokens issued via HTTP `/auth/login`.
3521    pub fn auth_store(&self) -> Option<Arc<crate::auth::store::AuthStore>> {
3522        self.inner.auth_store.read().clone()
3523    }
3524
3525    /// Read a vault KV secret from the configured AuthStore, if present.
3526    pub fn vault_kv_get(&self, key: &str) -> Option<String> {
3527        self.inner
3528            .auth_store
3529            .read()
3530            .as_ref()
3531            .and_then(|store| store.vault_kv_get(key))
3532    }
3533
3534    /// Write a vault KV secret and fail if the encrypted vault write is
3535    /// unavailable or cannot be made durable.
3536    pub fn vault_kv_try_set(&self, key: String, value: String) -> RedDBResult<()> {
3537        let store = self.inner.auth_store.read().clone().ok_or_else(|| {
3538            RedDBError::Query("secret storage requires an enabled, unsealed vault".to_string())
3539        })?;
3540        store
3541            .vault_kv_try_set(key, value)
3542            .map_err(|err| RedDBError::Query(err.to_string()))
3543    }
3544
3545    /// Inject an `OAuthValidator` into the runtime. When set, HTTP and
3546    /// wire transports try OAuth JWT validation before falling back to
3547    /// the local AuthStore lookup. Pass `None` to disable.
3548    pub fn set_oauth_validator(&self, validator: Option<Arc<crate::auth::oauth::OAuthValidator>>) {
3549        *self.inner.oauth_validator.write() = validator;
3550    }
3551
3552    /// Returns a clone of the configured `OAuthValidator` Arc, if any.
3553    /// Hot path: called per HTTP request when an Authorization header
3554    /// is present, so we hand back a cheap Arc clone.
3555    pub fn oauth_validator(&self) -> Option<Arc<crate::auth::oauth::OAuthValidator>> {
3556        self.inner.oauth_validator.read().clone()
3557    }
3558
3559    /// Inject the browser-token authority (issue #936). When set, the
3560    /// RedWire WS handshake accepts the short-lived access JWT it mints
3561    /// (alongside, and tried before, the federated OAuth validator), and
3562    /// the `/auth/browser/*` HTTP endpoints can issue/rotate the pair.
3563    /// `None` leaves the browser credential flow inert.
3564    pub fn set_browser_token_authority(
3565        &self,
3566        authority: Option<Arc<crate::auth::browser_token::BrowserTokenAuthority>>,
3567    ) {
3568        *self.inner.browser_token_authority.write() = authority;
3569    }
3570
3571    /// Snapshot the browser-token authority, if wired. Read on the WS
3572    /// handshake path and by the `/auth/browser/*` handlers; a cheap Arc
3573    /// clone keeps the lock hold short.
3574    pub fn browser_token_authority(
3575        &self,
3576    ) -> Option<Arc<crate::auth::browser_token::BrowserTokenAuthority>> {
3577        self.inner.browser_token_authority.read().clone()
3578    }
3579
3580    /// Returns the vault AES key (`red.secret.aes_key`) if an auth
3581    /// store is wired and a key has been generated. Used by the
3582    /// `Value::Secret` encrypt/decrypt pipeline.
3583    pub(crate) fn secret_aes_key(&self) -> Option<[u8; 32]> {
3584        let guard = self.inner.auth_store.read();
3585        guard.as_ref().and_then(|s| s.vault_secret_key())
3586    }
3587
3588    /// Resolve a boolean flag from `red_config`. Defaults to `default`
3589    /// when the key is missing or not coercible. If the same key has
3590    /// been written multiple times (SET CONFIG appends new rows), the
3591    /// most recent entity wins. Env-var overrides
3592    /// (`REDDB_<UP_DOTTED>`) take highest precedence.
3593    pub(crate) fn config_bool(&self, key: &str, default: bool) -> bool {
3594        if let Some(raw) = self.inner.env_config_overrides.get(key) {
3595            if let Some(crate::storage::schema::Value::Boolean(b)) =
3596                crate::runtime::config_overlay::coerce_env_value(key, raw)
3597            {
3598                return b;
3599            }
3600        }
3601        let store = self.inner.db.store();
3602        let Some(manager) = store.get_collection("red_config") else {
3603            return default;
3604        };
3605        let mut result = default;
3606        let mut latest_id: u64 = 0;
3607        manager.for_each_entity(|entity| {
3608            if let Some(row) = entity.data.as_row() {
3609                let entry_key = row.get_field("key").and_then(|v| match v {
3610                    crate::storage::schema::Value::Text(s) => Some(s.as_ref()),
3611                    _ => None,
3612                });
3613                if entry_key == Some(key) {
3614                    let id = entity.id.raw();
3615                    if id >= latest_id {
3616                        latest_id = id;
3617                        result = match row.get_field("value") {
3618                            Some(crate::storage::schema::Value::Boolean(b)) => *b,
3619                            Some(crate::storage::schema::Value::Text(s)) => {
3620                                matches!(s.as_ref(), "true" | "TRUE" | "True" | "1")
3621                            }
3622                            Some(crate::storage::schema::Value::Integer(n)) => *n != 0,
3623                            _ => default,
3624                        };
3625                    }
3626                }
3627            }
3628            true
3629        });
3630        result
3631    }
3632
3633    /// Whether DOCUMENT writes should store the body as the native binary
3634    /// container (PRD-1398, ADR-0063). On by default after the production
3635    /// cutover. Reads decode the container transparently regardless of this
3636    /// flag.
3637    pub(crate) fn binary_document_body_enabled(&self) -> bool {
3638        self.config_bool("storage.binary_document_body", true)
3639    }
3640
3641    pub(crate) fn config_u64(&self, key: &str, default: u64) -> u64 {
3642        if let Some(raw) = self.inner.env_config_overrides.get(key) {
3643            if let Some(crate::storage::schema::Value::UnsignedInteger(n)) =
3644                crate::runtime::config_overlay::coerce_env_value(key, raw)
3645            {
3646                return n;
3647            }
3648        }
3649        let store = self.inner.db.store();
3650        let Some(manager) = store.get_collection("red_config") else {
3651            return default;
3652        };
3653        let mut result = default;
3654        let mut latest_id: u64 = 0;
3655        manager.for_each_entity(|entity| {
3656            if let Some(row) = entity.data.as_row() {
3657                let entry_key = row.get_field("key").and_then(|v| match v {
3658                    crate::storage::schema::Value::Text(s) => Some(s.as_ref()),
3659                    _ => None,
3660                });
3661                if entry_key == Some(key) {
3662                    let id = entity.id.raw();
3663                    if id >= latest_id {
3664                        latest_id = id;
3665                        result = match row.get_field("value") {
3666                            Some(crate::storage::schema::Value::Integer(n)) => *n as u64,
3667                            Some(crate::storage::schema::Value::UnsignedInteger(n)) => *n,
3668                            Some(crate::storage::schema::Value::Text(s)) => {
3669                                s.parse::<u64>().unwrap_or(default)
3670                            }
3671                            _ => default,
3672                        };
3673                    }
3674                }
3675            }
3676            true
3677        });
3678        result
3679    }
3680
3681    pub(crate) fn config_f64(&self, key: &str, default: f64) -> f64 {
3682        if let Some(raw) = self.inner.env_config_overrides.get(key) {
3683            if let Ok(n) = raw.parse::<f64>() {
3684                return n;
3685            }
3686        }
3687        let store = self.inner.db.store();
3688        let Some(manager) = store.get_collection("red_config") else {
3689            return default;
3690        };
3691        let mut result = default;
3692        let mut latest_id: u64 = 0;
3693        manager.for_each_entity(|entity| {
3694            if let Some(row) = entity.data.as_row() {
3695                let entry_key = row.get_field("key").and_then(|v| match v {
3696                    crate::storage::schema::Value::Text(s) => Some(s.as_ref()),
3697                    _ => None,
3698                });
3699                if entry_key == Some(key) {
3700                    let id = entity.id.raw();
3701                    if id >= latest_id {
3702                        latest_id = id;
3703                        result = match row.get_field("value") {
3704                            Some(crate::storage::schema::Value::Float(n)) => *n,
3705                            Some(crate::storage::schema::Value::Integer(n)) => *n as f64,
3706                            Some(crate::storage::schema::Value::UnsignedInteger(n)) => *n as f64,
3707                            Some(crate::storage::schema::Value::Text(s)) => {
3708                                s.parse::<f64>().unwrap_or(default)
3709                            }
3710                            _ => default,
3711                        };
3712                    }
3713                }
3714            }
3715            true
3716        });
3717        result
3718    }
3719
3720    pub(crate) fn config_string(&self, key: &str, default: &str) -> String {
3721        if let Some(raw) = self.inner.env_config_overrides.get(key) {
3722            return raw.clone();
3723        }
3724        let store = self.inner.db.store();
3725        let Some(manager) = store.get_collection("red_config") else {
3726            return default.to_string();
3727        };
3728        let mut result = default.to_string();
3729        let mut latest_id: u64 = 0;
3730        manager.for_each_entity(|entity| {
3731            if let Some(row) = entity.data.as_row() {
3732                let entry_key = row.get_field("key").and_then(|v| match v {
3733                    crate::storage::schema::Value::Text(s) => Some(s.as_ref()),
3734                    _ => None,
3735                });
3736                if entry_key == Some(key) {
3737                    let id = entity.id.raw();
3738                    if id >= latest_id {
3739                        latest_id = id;
3740                        if let Some(crate::storage::schema::Value::Text(value)) =
3741                            row.get_field("value")
3742                        {
3743                            result = value.to_string();
3744                        }
3745                    }
3746                }
3747            }
3748            true
3749        });
3750        result
3751    }
3752
3753    /// Whether `SECRET('...')` literals should be encrypted with the
3754    /// vault AES key on INSERT. Default `true`.
3755    pub(crate) fn secret_auto_encrypt(&self) -> bool {
3756        self.config_bool("red.config.secret.auto_encrypt", true)
3757    }
3758
3759    /// Whether `Value::Secret` columns should be decrypted back to
3760    /// plaintext on SELECT when the vault is unsealed. Default `true`.
3761    /// Turning this off keeps secrets masked as `***` even while the
3762    /// vault is open — useful for audit trails or read-only exports.
3763    pub(crate) fn secret_auto_decrypt(&self) -> bool {
3764        self.config_bool("red.config.secret.auto_decrypt", true)
3765    }
3766
3767    /// Walk every record in `result` and swap `Value::Secret(bytes)`
3768    /// for the decrypted plaintext when the runtime has the vault
3769    /// AES key AND `red.config.secret.auto_decrypt = true`. If the
3770    /// key is missing, the vault is sealed, or auto_decrypt is off,
3771    /// secrets are left as `Value::Secret` which every formatter
3772    /// (Display, JSON) already masks as `***`.
3773    pub(crate) fn apply_secret_decryption(&self, result: &mut RuntimeQueryResult) {
3774        if !self.secret_auto_decrypt() {
3775            return;
3776        }
3777        let Some(key) = self.secret_aes_key() else {
3778            return;
3779        };
3780        for record in result.result.records.iter_mut() {
3781            for value in record.values_mut() {
3782                if let Value::Secret(ref bytes) = value {
3783                    if let Some(plain) =
3784                        super::impl_dml::decrypt_secret_payload(&key, bytes.as_slice())
3785                    {
3786                        if let Ok(text) = String::from_utf8(plain) {
3787                            *value = Value::text(text);
3788                        }
3789                    }
3790                }
3791            }
3792        }
3793    }
3794
3795    /// Emit a CDC change event and replicate to WAL buffer.
3796    /// Create a `MutationEngine` bound to this runtime.
3797    ///
3798    /// The engine is cheap to construct (no allocation) and should be
3799    /// dropped after `apply` returns. Use this from application-layer
3800    /// `create_row` / `create_rows_batch` instead of calling
3801    /// `bulk_insert` + `index_entity_insert` + `cdc_emit` separately.
3802    pub(crate) fn mutation_engine(&self) -> crate::runtime::mutation::MutationEngine<'_> {
3803        crate::runtime::mutation::MutationEngine::new(self)
3804    }
3805
3806    /// Public-mutation gate snapshot (PLAN.md W1).
3807    ///
3808    /// Surfaces that accept untrusted client requests (SQL DML/DDL,
3809    /// gRPC mutating RPCs, HTTP/native wire mutations, admin
3810    /// maintenance, serverless lifecycle) call `check_write` before
3811    /// dispatching to storage. Returns `RedDBError::ReadOnly` on any
3812    /// instance running as a replica or with `options.read_only =
3813    /// true`. The replica internal logical-WAL apply path reaches into
3814    /// the store directly and never calls this method, so legitimate
3815    /// replica catch-up still works.
3816    pub fn check_write(&self, kind: crate::runtime::write_gate::WriteKind) -> RedDBResult<()> {
3817        self.inner.write_gate.check(kind)
3818    }
3819
3820    /// Read-only handle to the gate, useful for transports that want
3821    /// to surface the policy in health/status output without taking on
3822    /// a dependency on the concrete enum.
3823    pub fn write_gate(&self) -> &crate::runtime::write_gate::WriteGate {
3824        &self.inner.write_gate
3825    }
3826
3827    /// Process lifecycle handle (PLAN.md Phase 1). Health probes,
3828    /// admin/shutdown, and signal handlers consult this single
3829    /// state machine.
3830    pub fn lifecycle(&self) -> &crate::runtime::lifecycle::Lifecycle {
3831        &self.inner.lifecycle
3832    }
3833
3834    /// Operator-imposed resource limits (PLAN.md Phase 4.1).
3835    pub fn resource_limits(&self) -> &crate::runtime::resource_limits::ResourceLimits {
3836        &self.inner.resource_limits
3837    }
3838
3839    /// Append-only audit log for admin mutations (PLAN.md Phase 6.5).
3840    pub fn audit_log(&self) -> &crate::runtime::audit_log::AuditLogger {
3841        &self.inner.audit_log
3842    }
3843
3844    /// Shared `Arc` to the audit logger — used by collaborators (the
3845    /// lease lifecycle, future request-context plumbing) that need to
3846    /// keep the logger alive past the runtime's stack frame.
3847    pub fn audit_log_arc(&self) -> Arc<crate::runtime::audit_log::AuditLogger> {
3848        Arc::clone(&self.inner.audit_log)
3849    }
3850
3851    pub(crate) fn emit_control_event(
3852        &self,
3853        kind: crate::runtime::control_events::EventKind,
3854        outcome: crate::runtime::control_events::Outcome,
3855        action: &'static str,
3856        resource: Option<String>,
3857        reason: Option<String>,
3858        extra_fields: Vec<(String, crate::runtime::control_events::Sensitivity)>,
3859    ) -> RedDBResult<()> {
3860        use crate::runtime::control_events::{
3861            ActorRef, ControlEvent, ControlEventCtx, ControlEventLedger, Sensitivity,
3862        };
3863
3864        let tenant = current_tenant();
3865        let principal = current_auth_identity();
3866        let actor_user = principal
3867            .as_ref()
3868            .map(|(principal, _)| UserId::from_parts(tenant.as_deref(), principal));
3869        let actor = actor_user
3870            .as_ref()
3871            .map(ActorRef::User)
3872            .unwrap_or(ActorRef::Anonymous);
3873        let ctx = ControlEventCtx {
3874            actor,
3875            scope: tenant
3876                .as_ref()
3877                .map(|scope| std::borrow::Cow::Borrowed(scope.as_str())),
3878            request_id: Some(std::borrow::Cow::Owned(format!(
3879                "conn-{}",
3880                current_connection_id()
3881            ))),
3882            trace_id: None,
3883        };
3884        let mut fields = std::collections::HashMap::new();
3885        fields.insert(
3886            "connection_id".to_string(),
3887            Sensitivity::raw(current_connection_id().to_string()),
3888        );
3889        if let Some((_, role)) = principal {
3890            fields.insert("actor_role".to_string(), Sensitivity::raw(role.as_str()));
3891        }
3892        for (key, value) in extra_fields {
3893            fields.insert(key, value);
3894        }
3895        let event = ControlEvent {
3896            kind,
3897            outcome,
3898            action: std::borrow::Cow::Borrowed(action),
3899            resource,
3900            reason,
3901            matched_policy_id: None,
3902            fields,
3903        };
3904        let ledger = self.inner.control_event_ledger.read();
3905        match ledger.emit(&ctx, event) {
3906            Ok(_) => Ok(()),
3907            Err(err) if self.inner.control_event_config.require_persistence() => {
3908                Err(RedDBError::Internal(err.to_string()))
3909            }
3910            Err(_) => Ok(()),
3911        }
3912    }
3913
3914    fn policy_mutation_control_ctx<'a>(
3915        &self,
3916        actor: &'a crate::auth::UserId,
3917        tenant: Option<&'a str>,
3918    ) -> crate::runtime::control_events::ControlEventCtx<'a> {
3919        crate::runtime::control_events::ControlEventCtx {
3920            actor: crate::runtime::control_events::ActorRef::User(actor),
3921            scope: tenant.map(std::borrow::Cow::Borrowed),
3922            request_id: Some(std::borrow::Cow::Owned(format!(
3923                "conn-{}",
3924                current_connection_id()
3925            ))),
3926            trace_id: None,
3927        }
3928    }
3929
3930    fn emit_query_audit(
3931        &self,
3932        query: &str,
3933        plan: &QueryAuditPlan,
3934        duration_ms: u64,
3935        result: &RuntimeQueryResult,
3936    ) {
3937        if !self.inner.query_audit.has_rules() {
3938            return;
3939        }
3940        let actor = current_auth_identity().map(|(principal, _)| principal);
3941        let tenant = current_tenant();
3942        let row_count = if result.statement_type == "select" {
3943            result.result.records.len() as u64
3944        } else {
3945            result.affected_rows
3946        };
3947        self.inner
3948            .query_audit
3949            .emit(crate::runtime::query_audit::QueryAuditEvent {
3950                actor,
3951                tenant,
3952                statement_kind: plan.statement_kind,
3953                touched_collections: plan.collections.clone(),
3954                duration_ms,
3955                row_count,
3956                request_id: Some(crate::crypto::uuid::Uuid::new_v7().to_string()),
3957                query_hash: Some(blake3::hash(query.as_bytes()).to_hex().to_string()),
3958            });
3959    }
3960
3961    /// Shared queue telemetry counters (delivered/acked/nacked).
3962    pub(crate) fn queue_telemetry(
3963        &self,
3964    ) -> &crate::runtime::queue_telemetry::QueueTelemetryCounters {
3965        &self.inner.queue_telemetry
3966    }
3967
3968    /// Snapshots of the queue telemetry counters in label-deterministic
3969    /// order for `/metrics` rendering and the integration test.
3970    pub fn queue_telemetry_snapshot(
3971        &self,
3972    ) -> crate::runtime::queue_telemetry::QueueTelemetrySnapshot {
3973        crate::runtime::queue_telemetry::QueueTelemetrySnapshot {
3974            delivered: self.inner.queue_telemetry.delivered_snapshot(),
3975            acked: self.inner.queue_telemetry.acked_snapshot(),
3976            nacked: self.inner.queue_telemetry.nacked_snapshot(),
3977            wait_started: self.inner.queue_telemetry.wait_started_snapshot(),
3978            wait_woken: self.inner.queue_telemetry.wait_woken_snapshot(),
3979            wait_timed_out: self.inner.queue_telemetry.wait_timed_out_snapshot(),
3980            wait_cancelled: self.inner.queue_telemetry.wait_cancelled_snapshot(),
3981            wait_duration: self.inner.queue_telemetry.wait_duration_snapshot(),
3982        }
3983    }
3984
3985    /// Snapshots of Concurrent claim counters in label-deterministic order.
3986    pub fn claim_telemetry_snapshot(&self) -> crate::runtime::ClaimTelemetrySnapshot {
3987        self.inner.claim_telemetry.snapshot()
3988    }
3989
3990    /// Per-`kind` query latency histograms for `/metrics` (only kinds with
3991    /// a real sample are present — empty kinds are absent, not zero-filled).
3992    pub fn query_latency_snapshot(
3993        &self,
3994    ) -> Vec<crate::runtime::query_latency_telemetry::QueryLatencyHistogram> {
3995        self.inner.query_latency_telemetry.snapshot()
3996    }
3997
3998    /// Cross-kind query latency rollup for `/cluster/status` and the
3999    /// red-ui percentile panels. `count == 0` until a real sample exists.
4000    pub fn query_latency_rollup(
4001        &self,
4002    ) -> crate::runtime::query_latency_telemetry::QueryLatencyHistogram {
4003        self.inner.query_latency_telemetry.rollup()
4004    }
4005
4006    /// Issue #1244 — take a fresh node CPU/RAM occupancy reading for
4007    /// `/cluster/status`. CPU utilisation is measured over the interval
4008    /// since the previous call (the first call only establishes a baseline
4009    /// and reports `NotSampled`). See `occupancy_sampler` for overhead.
4010    pub fn sample_occupancy(&self) -> crate::runtime::occupancy_sampler::OccupancySample {
4011        self.inner.occupancy_sampler.sample()
4012    }
4013
4014    /// Issue #1245 — point-in-time node load snapshot (active queries +
4015    /// connect/disconnect churn). Feeds `/metrics`, `/cluster/status`, and
4016    /// the red-ui load panels.
4017    pub fn node_load_snapshot(&self) -> crate::runtime::node_load_telemetry::NodeLoadSnapshot {
4018        self.inner.node_load_telemetry.snapshot()
4019    }
4020
4021    /// Issue #742 — consumer presence registry. Heartbeats land here
4022    /// from `QUEUE READ` (and, in a follow-up slice, an explicit
4023    /// `QUEUE HEARTBEAT` command); Red UI and `red.queue_consumers`
4024    /// read snapshots through `queue_consumer_presence_snapshot`.
4025    pub(crate) fn queue_presence(
4026        &self,
4027    ) -> &std::sync::Arc<crate::storage::queue::presence::ConsumerPresenceRegistry> {
4028        &self.inner.queue_presence
4029    }
4030
4031    /// Issue #742 — point-in-time presence snapshot, classifying each
4032    /// `(queue, group, consumer)` as active/stale/expired against the
4033    /// supplied TTL. Wall-clock is read once here so the lifecycle
4034    /// flags inside the snapshot are internally consistent.
4035    pub fn queue_consumer_presence_snapshot(
4036        &self,
4037        ttl_ms: u64,
4038    ) -> Vec<crate::storage::queue::presence::ConsumerPresence> {
4039        let now_ns = std::time::SystemTime::now()
4040            .duration_since(std::time::UNIX_EPOCH)
4041            .map(|d| d.as_nanos() as u64)
4042            .unwrap_or(0);
4043        self.inner.queue_presence.snapshot(now_ns, ttl_ms)
4044    }
4045
4046    /// Issue #742 — active-consumer count per `(queue, group)` for the
4047    /// queue-metadata surface. Stale/expired entries are excluded by
4048    /// definition; they are still visible in the per-row snapshot.
4049    pub fn queue_active_consumer_counts(
4050        &self,
4051        ttl_ms: u64,
4052    ) -> std::collections::HashMap<(String, String), u32> {
4053        let now_ns = std::time::SystemTime::now()
4054            .duration_since(std::time::UNIX_EPOCH)
4055            .map(|d| d.as_nanos() as u64)
4056            .unwrap_or(0);
4057        self.inner
4058            .queue_presence
4059            .count_active_by_group(now_ns, ttl_ms)
4060    }
4061
4062    /// Issue #743 — vector + TurboQuant introspection registry. Engine
4063    /// publish points (collection create, artifact build start /
4064    /// finish, fallback toggle, drop) update this; Red UI and
4065    /// `red.*` vector virtual tables read snapshots through
4066    /// `vector_introspection_snapshot` / `vector_introspection_get`.
4067    pub(crate) fn vector_introspection_registry(
4068        &self,
4069    ) -> &std::sync::Arc<crate::storage::vector::introspection::VectorIntrospectionRegistry> {
4070        &self.inner.vector_introspection
4071    }
4072
4073    /// Issue #743 — full snapshot of every tracked vector collection's
4074    /// `(VectorMetadata, ArtifactMetadata)`. Deterministically ordered
4075    /// by collection name so Red UI tables and tests both see a
4076    /// stable shape.
4077    pub fn vector_introspection_snapshot(
4078        &self,
4079    ) -> Vec<crate::storage::vector::introspection::VectorIntrospection> {
4080        self.inner.vector_introspection.snapshot()
4081    }
4082
4083    /// Issue #743 — single-collection lookup, for the per-collection
4084    /// metadata endpoint Red UI hits when an operator opens one
4085    /// vector's toolbar.
4086    pub fn vector_introspection_get(
4087        &self,
4088        collection: &str,
4089    ) -> Option<crate::storage::vector::introspection::VectorIntrospection> {
4090        self.inner.vector_introspection.get(collection)
4091    }
4092
4093    /// Issue #1238 — ADR 0060 read-model accessor for slow-query telemetry.
4094    ///
4095    /// Returns a reference to the bounded ring store so HTTP handlers and
4096    /// the red-ui read model can call `store.read(filter)` without
4097    /// touching `red-slow.log` directly.
4098    pub fn slow_query_store(&self) -> &Arc<crate::telemetry::slow_query_store::SlowQueryStore> {
4099        &self.inner.slow_query_store
4100    }
4101
4102    /// Slice 10 of issue #527 — render-time scan of pending entries
4103    /// per (queue, group) for the `queue_pending_gauge` exposition.
4104    /// Walks `red_queue_meta` live so the gauge cannot drift from
4105    /// the source of truth.
4106    pub fn queue_pending_counts(&self) -> Vec<((String, String), u64)> {
4107        let store = self.inner.db.store();
4108        crate::runtime::impl_queue::pending_counts_by_group(store.as_ref())
4109            .into_iter()
4110            .collect()
4111    }
4112
4113    /// Shared `Arc` to the write gate. Same rationale as
4114    /// `audit_log_arc`: collaborators (lease lifecycle, refresh
4115    /// thread) need a clone-cheap handle they can move into a
4116    /// background thread.
4117    pub fn write_gate_arc(&self) -> Arc<crate::runtime::write_gate::WriteGate> {
4118        Arc::clone(&self.inner.write_gate)
4119    }
4120
4121    /// Serverless writer-lease state machine. `None` when the operator
4122    /// did not opt into lease fencing (`RED_LEASE_REQUIRED` unset).
4123    pub fn lease_lifecycle(&self) -> Option<&Arc<crate::runtime::lease_lifecycle::LeaseLifecycle>> {
4124        self.inner.lease_lifecycle.get()
4125    }
4126
4127    /// Install the lease lifecycle. Idempotent; subsequent calls
4128    /// return the previously stored value untouched.
4129    pub fn set_lease_lifecycle(
4130        &self,
4131        lifecycle: Arc<crate::runtime::lease_lifecycle::LeaseLifecycle>,
4132    ) -> Result<(), Arc<crate::runtime::lease_lifecycle::LeaseLifecycle>> {
4133        self.inner.lease_lifecycle.set(lifecycle)
4134    }
4135
4136    /// Reject the call when the requested batch size exceeds
4137    /// `RED_MAX_BATCH_SIZE`. Returns `RedDBError::QuotaExceeded`
4138    /// shaped so the HTTP layer can map it to 413 Payload Too
4139    /// Large (PLAN.md Phase 4.1).
4140    pub fn check_batch_size(&self, requested: usize) -> RedDBResult<()> {
4141        if self.inner.resource_limits.batch_size_exceeded(requested) {
4142            let max = self.inner.resource_limits.max_batch_size.unwrap_or(0);
4143            return Err(RedDBError::QuotaExceeded(format!(
4144                "max_batch_size:{requested}:{max}"
4145            )));
4146        }
4147        Ok(())
4148    }
4149
4150    /// Reject the call when the local DB file exceeds
4151    /// `RED_MAX_DB_SIZE_BYTES`. Reads file metadata once per call —
4152    /// the cost is a single `stat()` syscall, negligible against the
4153    /// I/O the caller is about to do. Returns `QuotaExceeded` shaped
4154    /// for HTTP 507 Insufficient Storage.
4155    pub fn check_db_size(&self) -> RedDBResult<()> {
4156        let Some(limit) = self.inner.resource_limits.max_db_size_bytes else {
4157            return Ok(());
4158        };
4159        if limit == 0 {
4160            return Ok(());
4161        }
4162        let Some(path) = self.inner.db.path() else {
4163            return Ok(());
4164        };
4165        let current = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
4166        if current > limit {
4167            return Err(RedDBError::QuotaExceeded(format!(
4168                "max_db_size_bytes:{current}:{limit}"
4169            )));
4170        }
4171        Ok(())
4172    }
4173
4174    /// Graceful shutdown coordinator (PLAN.md Phase 1.1).
4175    ///
4176    /// Steps, in order, all idempotent across re-entrant calls:
4177    ///   1. Move lifecycle into `ShuttingDown` (concurrent callers
4178    ///      observe `Stopped` after first finishes).
4179    ///   2. Flush WAL + run final checkpoint via `db.flush()` so
4180    ///      every acked write is durable on disk.
4181    ///   3. If `backup_on_shutdown == true` and a remote backend is
4182    ///      configured, run a synchronous `trigger_backup()` so the
4183    ///      remote head reflects the final state.
4184    ///   4. Stamp the report and move to `Stopped`. Subsequent calls
4185    ///      return the cached report without re-running anything.
4186    ///
4187    /// On any error, the runtime is still marked `Stopped` so the
4188    /// process can exit; the caller logs the error context but does
4189    /// not retry the same shutdown — the operator can inspect the
4190    /// report fields to see which step failed.
4191    pub fn graceful_shutdown(
4192        &self,
4193        backup_on_shutdown: bool,
4194    ) -> RedDBResult<crate::runtime::lifecycle::ShutdownReport> {
4195        if !self.inner.lifecycle.begin_shutdown() {
4196            // Someone else already shut down (or is in flight). Return
4197            // the cached report so the HTTP caller and SIGTERM handler
4198            // get the same idempotent answer.
4199            return Ok(self.inner.lifecycle.shutdown_report().unwrap_or_default());
4200        }
4201
4202        let started_ms = std::time::SystemTime::now()
4203            .duration_since(std::time::UNIX_EPOCH)
4204            .map(|d| d.as_millis() as u64)
4205            .unwrap_or(0);
4206        let mut report = crate::runtime::lifecycle::ShutdownReport {
4207            started_at_ms: started_ms,
4208            ..Default::default()
4209        };
4210
4211        // Flush WAL + run any pending checkpoint. Local fsync is
4212        // unconditional — even a lease-lost replica needs its WAL on
4213        // disk before exit so a future restore has the latest tail.
4214        // The remote upload is gated separately so a lost-lease writer
4215        // doesn't clobber the new holder's state on its way out.
4216        let flush_res = self.inner.db.flush_local_only();
4217        report.flushed_wal = flush_res.is_ok();
4218        report.final_checkpoint = flush_res.is_ok();
4219        if let Err(err) = &flush_res {
4220            tracing::error!(
4221                target: "reddb::lifecycle",
4222                error = %err,
4223                "graceful_shutdown: local flush failed"
4224            );
4225        } else if let Err(lease_err) =
4226            self.assert_remote_write_allowed("shutdown/checkpoint_upload")
4227        {
4228            tracing::warn!(
4229                target: "reddb::serverless::lease",
4230                error = %lease_err,
4231                "graceful_shutdown: remote upload skipped — lease not held"
4232            );
4233        } else if let Err(err) = self.inner.db.upload_to_remote_backend() {
4234            tracing::error!(
4235                target: "reddb::lifecycle",
4236                error = %err,
4237                "graceful_shutdown: remote upload failed"
4238            );
4239        }
4240
4241        // Optional final backup. Skipped silently when no remote
4242        // backend is configured — `trigger_backup()` returns Err
4243        // anyway in that case, but logging it as a shutdown failure
4244        // would be misleading on a standalone (no-backend) runtime.
4245        if backup_on_shutdown && self.inner.db.remote_backend.is_some() {
4246            // The trigger_backup gate now reads `WriteKind::Backup`,
4247            // which a replica/read_only instance refuses. That's
4248            // intentional — replicas don't drive backups; only the
4249            // primary does. We still want shutdown to flush its WAL
4250            // even if the backup branch is gated off.
4251            match self.trigger_backup() {
4252                Ok(result) => {
4253                    report.backup_uploaded = result.uploaded;
4254                }
4255                Err(err) => {
4256                    tracing::warn!(
4257                        target: "reddb::lifecycle",
4258                        error = %err,
4259                        "graceful_shutdown: final backup skipped"
4260                    );
4261                }
4262            }
4263        }
4264
4265        let completed_ms = std::time::SystemTime::now()
4266            .duration_since(std::time::UNIX_EPOCH)
4267            .map(|d| d.as_millis() as u64)
4268            .unwrap_or(started_ms);
4269        report.completed_at_ms = completed_ms;
4270        report.duration_ms = completed_ms.saturating_sub(started_ms);
4271
4272        self.inner.lifecycle.finish_shutdown(report.clone());
4273        Ok(report)
4274    }
4275
4276    /// PLAN.md Phase 4.4 — per-caller quota bucket. Always
4277    /// returned; `is_configured()` lets callers short-circuit.
4278    pub fn quota_bucket(&self) -> &crate::runtime::quota_bucket::QuotaBucket {
4279        &self.inner.quota_bucket
4280    }
4281
4282    /// PLAN.md Phase 6.3 — whether at-rest encryption is configured.
4283    /// Reads `RED_ENCRYPTION_KEY` / `RED_ENCRYPTION_KEY_FILE` lazily;
4284    /// returns `("enabled", None)` when a key is loadable, `("error", Some(msg))`
4285    /// when the operator set the env but it doesn't parse, and
4286    /// `("disabled", None)` when no key is configured. The pager
4287    /// hookup is deferred — this accessor surfaces the operator's
4288    /// intent for /admin/status without yet using the key in writes.
4289    pub fn encryption_at_rest_status(&self) -> (&'static str, Option<String>) {
4290        match crate::crypto::page_encryption::key_from_env() {
4291            Ok(Some(_)) => ("enabled", None),
4292            Ok(None) => ("disabled", None),
4293            Err(err) => ("error", Some(err)),
4294        }
4295    }
4296
4297    /// PLAN.md Phase 11.5 — current replica apply health label
4298    /// (`ok`, `gap`, `divergence`, `apply_error`, `connecting`,
4299    /// `stalled_gap`). Read from the persisted `red.replication.state`
4300    /// config key updated by the replica loop. Returns `None` on
4301    /// non-replica instances or when no apply has run yet.
4302    pub fn replica_apply_health(&self) -> Option<String> {
4303        let state = self.config_string("red.replication.state", "");
4304        if state.is_empty() {
4305            None
4306        } else {
4307            Some(state)
4308        }
4309    }
4310
4311    pub fn acquire(&self) -> RedDBResult<RuntimeConnection> {
4312        let mut pool = self
4313            .inner
4314            .pool
4315            .lock()
4316            .map_err(|e| RedDBError::Internal(format!("connection pool lock poisoned: {e}")))?;
4317        if pool.active >= self.inner.pool_config.max_connections {
4318            return Err(RedDBError::Internal(
4319                "connection pool exhausted".to_string(),
4320            ));
4321        }
4322
4323        let id = if let Some(id) = pool.idle.pop() {
4324            id
4325        } else {
4326            let id = pool.next_id;
4327            pool.next_id += 1;
4328            id
4329        };
4330        pool.active += 1;
4331        pool.total_checkouts += 1;
4332        drop(pool);
4333
4334        // Issue #1245 — record the connection acquisition after releasing
4335        // the pool lock so the lock hold time is unchanged.
4336        self.inner.node_load_telemetry.record_connect();
4337
4338        Ok(RuntimeConnection {
4339            id,
4340            inner: Arc::clone(&self.inner),
4341        })
4342    }
4343
4344    pub fn checkpoint(&self) -> RedDBResult<()> {
4345        // Local fsync always allowed — losing the lease shouldn't
4346        // prevent us from durably persisting what's already in memory.
4347        // The remote upload is the side-effect that risks clobbering a
4348        // peer's state, so it's behind the lease gate.
4349        self.inner.db.flush_local_only().map_err(|err| {
4350            // Issue #205 — local flush failure is a CheckpointFailed
4351            // operator-grade event. The local-flush path also covers
4352            // the WAL fsync we depend on, so a failure here doubles as
4353            // the WalFsyncFailed signal for the runtime entry point.
4354            let msg = err.to_string();
4355            crate::telemetry::operator_event::OperatorEvent::CheckpointFailed {
4356                lsn: 0,
4357                error: msg.clone(),
4358            }
4359            .emit_global();
4360            crate::telemetry::operator_event::OperatorEvent::WalFsyncFailed {
4361                path: "<flush_local_only>".to_string(),
4362                error: msg.clone(),
4363            }
4364            .emit_global();
4365            RedDBError::Engine(msg)
4366        })?;
4367        if let Err(err) = self.assert_remote_write_allowed("checkpoint") {
4368            tracing::warn!(
4369                target: "reddb::serverless::lease",
4370                error = %err,
4371                "checkpoint: skipping remote upload — lease not held"
4372            );
4373            return Ok(());
4374        }
4375        self.inner
4376            .db
4377            .upload_to_remote_backend()
4378            .map_err(|err| RedDBError::Engine(err.to_string()))
4379    }
4380
4381    /// Guard remote-mutating operations on the writer lease.
4382    /// Returns `Ok(())` when no remote backend is configured (the
4383    /// lease is irrelevant) or the lease state is `NotRequired` /
4384    /// `Held`. Returns `RedDBError::ReadOnly` when the lease is
4385    /// `NotHeld`, with an audit-friendly action label so the caller
4386    /// can record the rejection.
4387    pub(crate) fn assert_remote_write_allowed(&self, action: &str) -> RedDBResult<()> {
4388        if self.inner.db.remote_backend.is_none() {
4389            return Ok(());
4390        }
4391        match self.inner.write_gate.lease_state() {
4392            crate::runtime::write_gate::LeaseGateState::NotHeld => {
4393                self.inner.audit_log.record(
4394                    action,
4395                    "system",
4396                    "remote_backend",
4397                    "err: writer lease not held",
4398                    crate::json::Value::Null,
4399                );
4400                Err(RedDBError::ReadOnly(format!(
4401                    "writer lease not held — {action} blocked (serverless fence)"
4402                )))
4403            }
4404            _ => Ok(()),
4405        }
4406    }
4407
4408    pub fn run_maintenance(&self) -> RedDBResult<()> {
4409        self.inner
4410            .db
4411            .run_maintenance()
4412            .map_err(|err| RedDBError::Internal(err.to_string()))
4413    }
4414
4415    pub fn scan_collection(
4416        &self,
4417        collection: &str,
4418        cursor: Option<ScanCursor>,
4419        limit: usize,
4420    ) -> RedDBResult<ScanPage> {
4421        let store = self.inner.db.store();
4422        let manager = store
4423            .get_collection(collection)
4424            .ok_or_else(|| RedDBError::NotFound(collection.to_string()))?;
4425
4426        let mut entities = manager.query_all(|_| true);
4427        entities.sort_by_key(|entity| entity.id.raw());
4428
4429        let offset = cursor.map(|cursor| cursor.offset).unwrap_or(0);
4430        let total = entities.len();
4431        let end = total.min(offset.saturating_add(limit.max(1)));
4432        let items = if offset >= total {
4433            Vec::new()
4434        } else {
4435            entities[offset..end].to_vec()
4436        };
4437        let next = (end < total).then_some(ScanCursor { offset: end });
4438
4439        Ok(ScanPage {
4440            collection: collection.to_string(),
4441            items,
4442            next,
4443            total,
4444        })
4445    }
4446
4447    pub fn catalog(&self) -> CatalogModelSnapshot {
4448        self.inner.db.catalog_model_snapshot()
4449    }
4450
4451    pub fn catalog_consistency_report(&self) -> crate::catalog::CatalogConsistencyReport {
4452        self.inner.db.catalog_consistency_report()
4453    }
4454
4455    pub fn catalog_attention_summary(&self) -> CatalogAttentionSummary {
4456        crate::catalog::attention_summary(&self.catalog())
4457    }
4458
4459    pub fn collection_attention(&self) -> Vec<CollectionDescriptor> {
4460        crate::catalog::collection_attention(&self.catalog())
4461    }
4462
4463    pub fn index_attention(&self) -> Vec<CatalogIndexStatus> {
4464        crate::catalog::index_attention(&self.catalog())
4465    }
4466
4467    pub fn graph_projection_attention(&self) -> Vec<CatalogGraphProjectionStatus> {
4468        crate::catalog::graph_projection_attention(&self.catalog())
4469    }
4470
4471    pub fn analytics_job_attention(&self) -> Vec<CatalogAnalyticsJobStatus> {
4472        crate::catalog::analytics_job_attention(&self.catalog())
4473    }
4474
4475    pub fn stats(&self) -> RuntimeStats {
4476        let pool = runtime_pool_lock(self);
4477        RuntimeStats {
4478            active_connections: pool.active,
4479            idle_connections: pool.idle.len(),
4480            total_checkouts: pool.total_checkouts,
4481            paged_mode: self.inner.db.is_paged(),
4482            started_at_unix_ms: self.inner.started_at_unix_ms,
4483            store: self.inner.db.stats(),
4484            system: SystemInfo::collect(),
4485            result_blob_cache: self.inner.result_blob_cache.stats(),
4486            kv: self.inner.kv_stats.snapshot(),
4487            metrics_ingest: self.inner.metrics_ingest_stats.snapshot(),
4488        }
4489    }
4490
4491    pub(crate) fn record_metrics_ingest(
4492        &self,
4493        accepted_samples: u64,
4494        accepted_series: u64,
4495        rejected_samples: u64,
4496        rejected_series: u64,
4497    ) {
4498        self.inner.metrics_ingest_stats.record(
4499            accepted_samples,
4500            accepted_series,
4501            rejected_samples,
4502            rejected_series,
4503        );
4504    }
4505
4506    pub(crate) fn record_metrics_cardinality_budget_rejections(&self, rejected_series: u64) {
4507        self.inner
4508            .metrics_ingest_stats
4509            .record_cardinality_budget_rejections(rejected_series);
4510    }
4511
4512    pub(crate) fn record_metrics_tenant_activity(
4513        &self,
4514        tenant: &str,
4515        namespace: &str,
4516        operation: &str,
4517    ) {
4518        self.inner
4519            .metrics_tenant_activity_stats
4520            .record(tenant, namespace, operation);
4521    }
4522
4523    pub(crate) fn metrics_tenant_activity_snapshot(
4524        &self,
4525    ) -> Vec<crate::runtime::MetricsTenantActivityStats> {
4526        self.inner.metrics_tenant_activity_stats.snapshot()
4527    }
4528
4529    /// Execute a query under a typed scope override without embedding
4530    /// the tenant / user / role values into the SQL string. Use this
4531    /// from transport middleware (HTTP / gRPC / worker loops) where the
4532    /// scope is resolved from auth claims and the SQL is a parameterised
4533    /// template — avoids the string-concat injection risk of building
4534    /// `WITHIN TENANT '<id>' …` manually, and is drop-in compatible with
4535    /// prepared statements that didn't know about tenancy.
4536    ///
4537    /// Precedence matches the `WITHIN` clause: the passed `scope`
4538    /// overrides `SET LOCAL TENANT`, which overrides `SET TENANT`.
4539    /// The override is pushed on the thread-local scope stack for the
4540    /// duration of the call and popped on return — pool-shared
4541    /// connections cannot leak it across requests.
4542    pub fn execute_query_with_scope(
4543        &self,
4544        query: &str,
4545        scope: crate::runtime::within_clause::ScopeOverride,
4546    ) -> RedDBResult<RuntimeQueryResult> {
4547        if scope.is_empty() {
4548            return self.execute_query(query);
4549        }
4550        let _scope_guard = ScopeOverrideGuard::install(scope);
4551        self.execute_query(query)
4552    }
4553
4554    /// Issue #205 — single lifecycle exit for slow-query logging.
4555    ///
4556    /// `execute_query_inner` does the real work; this wrapper times it
4557    /// and, if elapsed exceeds the configured threshold, hands the
4558    /// triple `(QueryKind, elapsed_ms, sql_redacted, scope)` to the
4559    /// SlowQueryLogger. The threshold + sample_pct were captured at
4560    /// SlowQueryLogger construction (runtime startup), so the per-call
4561    /// cost on below-threshold paths is one relaxed atomic load.
4562    pub fn execute_query(&self, query: &str) -> RedDBResult<RuntimeQueryResult> {
4563        let started = std::time::Instant::now();
4564        self.inner.node_load_telemetry.query_start();
4565        let result = self.execute_query_inner(query);
4566        self.finish_query_lifecycle(query, started, result)
4567    }
4568
4569    /// Execute a SQL statement with already-decoded positional bind
4570    /// parameters. Transports should call this instead of parsing +
4571    /// binding on their side and then reaching for `execute_query_expr`:
4572    /// this entry keeps parameterized statements inside the same
4573    /// statement lifecycle as textual SQL (snapshot guard, config/secret
4574    /// guards, coarse auth, intent locks, slow-query logging, integrity
4575    /// tombstone filtering, and causal bookmarks).
4576    pub fn execute_query_with_params(
4577        &self,
4578        query: &str,
4579        params: &[Value],
4580    ) -> RedDBResult<RuntimeQueryResult> {
4581        if params.is_empty() {
4582            return self.execute_query(query);
4583        }
4584        let started = std::time::Instant::now();
4585        self.inner.node_load_telemetry.query_start();
4586        let result = self.execute_query_with_params_inner(query, params);
4587        self.finish_query_lifecycle(query, started, result)
4588    }
4589
4590    fn finish_query_lifecycle(
4591        &self,
4592        query: &str,
4593        started: std::time::Instant,
4594        mut result: RedDBResult<RuntimeQueryResult>,
4595    ) -> RedDBResult<RuntimeQueryResult> {
4596        // Issue #765 / S6 — filter integrity-tombstoned rows out of SELECT
4597        // results before they reach any consumer. Fast no-op (one relaxed
4598        // atomic load) unless an input-stream digest mismatch has tombstoned
4599        // a RID range on this store.
4600        if let Ok(ref mut query_result) = result {
4601            if query_result.statement_type == "select" {
4602                self.filter_integrity_tombstoned(&mut query_result.result);
4603            }
4604        }
4605        let elapsed_ms = started.elapsed().as_millis() as u64;
4606
4607        // Build EffectiveScope from the same thread-locals frame-build
4608        // consults — keeps the slow-log row consistent with the audit /
4609        // RLS view of "this statement". `ai_scope()` is the canonical
4610        // builder.
4611        let scope = self.ai_scope();
4612        let kind = match result
4613            .as_ref()
4614            .map(|r| r.statement_type)
4615            .unwrap_or("select")
4616        {
4617            "select" => crate::telemetry::slow_query_logger::QueryKind::Select,
4618            "insert" => crate::telemetry::slow_query_logger::QueryKind::Insert,
4619            "update" => crate::telemetry::slow_query_logger::QueryKind::Update,
4620            "delete" => crate::telemetry::slow_query_logger::QueryKind::Delete,
4621            _ => crate::telemetry::slow_query_logger::QueryKind::Internal,
4622        };
4623        // SQL redaction: pass the raw query through. The slow-query
4624        // logger writes structured JSON so embedded literals stay
4625        // escape-safe at the JSON boundary (proven by
4626        // `adversarial_sql_is_escape_safe` in slow_query_logger.rs).
4627        // PII redaction (e.g. literal masking) is a follow-up.
4628        self.inner
4629            .slow_query_logger
4630            .record(kind, elapsed_ms, query.to_string(), &scope);
4631
4632        // Issue #1241 — record latency into the bounded per-`kind`
4633        // histogram substrate (always, not only above the slow-query
4634        // threshold). `started.elapsed()` is re-read here for sub-ms
4635        // resolution; the cost is one `Instant::now` plus a handful of
4636        // relaxed atomic adds (see `query_latency_telemetry` docs).
4637        self.inner
4638            .query_latency_telemetry
4639            .observe(kind, started.elapsed().as_secs_f64());
4640
4641        // Issue #1245 — decrement the active-query gauge. One relaxed
4642        // atomic sub; the matching increment happened at execute_query /
4643        // execute_query_with_params entry.
4644        self.inner.node_load_telemetry.query_finish();
4645
4646        if let Ok(ref mut query_result) = result {
4647            if matches!(query_result.statement_type, "insert" | "update" | "delete") {
4648                let bookmark = crate::replication::CausalBookmark::new(
4649                    self.current_replication_term(),
4650                    self.cdc_current_lsn(),
4651                );
4652                query_result.bookmark = Some(bookmark.encode());
4653            }
4654        }
4655
4656        result
4657    }
4658
4659    fn execute_query_with_params_inner(
4660        &self,
4661        query: &str,
4662        params: &[Value],
4663    ) -> RedDBResult<RuntimeQueryResult> {
4664        let parsed = parse_multi(query).map_err(|err| RedDBError::Query(err.to_string()))?;
4665        let bound = crate::storage::query::user_params::bind(&parsed, params).map_err(|err| {
4666            RedDBError::Validation {
4667                message: err.to_string(),
4668                validation: crate::json!({
4669                    "code": "INVALID_PARAMS",
4670                    "surface": "query.params",
4671                }),
4672            }
4673        })?;
4674        self.execute_bound_query_expr_in_frame(query, bound)
4675    }
4676
4677    fn execute_bound_query_expr_in_frame(
4678        &self,
4679        query: &str,
4680        expr: QueryExpr,
4681    ) -> RedDBResult<RuntimeQueryResult> {
4682        let rewritten_query = super::red_schema::rewrite_virtual_names(query);
4683        let execution_query = rewritten_query.as_deref().unwrap_or(query);
4684        let frame = super::statement_frame::StatementExecutionFrame::build(self, execution_query)?;
4685        let _frame_guards = frame.install(self);
4686        let _log_span = crate::telemetry::span::query_span(query).entered();
4687
4688        let expr = self.rewrite_view_refs(expr);
4689        let mode = detect_mode(execution_query);
4690        let control_event_specs = query_control_event_specs(&expr);
4691        let _lock_guard = match frame.prepare_dispatch(self, &expr) {
4692            Ok(guard) => guard,
4693            Err(err) => {
4694                let outcome = control_event_outcome_for_error(&err);
4695                for spec in &control_event_specs {
4696                    self.emit_control_event(
4697                        spec.kind,
4698                        outcome,
4699                        spec.action,
4700                        spec.resource.clone(),
4701                        Some(err.to_string()),
4702                        spec.fields.clone(),
4703                    )?;
4704                }
4705                return Err(err);
4706            }
4707        };
4708
4709        let mut result = self.dispatch_expr(expr, query, mode)?;
4710        if result.statement_type == "select" {
4711            self.apply_secret_decryption(&mut result);
4712        }
4713        Ok(result)
4714    }
4715
4716    pub fn causal_session(&self) -> crate::runtime::CausalSession {
4717        crate::runtime::CausalSession {
4718            runtime: self.clone(),
4719            bookmark: None,
4720            wait_timeout: std::time::Duration::from_secs(5),
4721        }
4722    }
4723
4724    pub fn wait_for_bookmark(
4725        &self,
4726        bookmark: &crate::replication::CausalBookmark,
4727        timeout: std::time::Duration,
4728    ) -> RedDBResult<()> {
4729        let deadline = std::time::Instant::now() + timeout;
4730        loop {
4731            let applied_lsn = self.local_contiguous_applied_lsn();
4732            if applied_lsn >= bookmark.commit_lsn() {
4733                return Ok(());
4734            }
4735            let now = std::time::Instant::now();
4736            if now >= deadline {
4737                return Err(RedDBError::InvalidOperation(format!(
4738                    "timed out waiting for causal bookmark lsn {}; applied={}",
4739                    bookmark.commit_lsn(),
4740                    applied_lsn
4741                )));
4742            }
4743            let remaining = deadline.saturating_duration_since(now);
4744            std::thread::sleep(remaining.min(std::time::Duration::from_millis(5)));
4745        }
4746    }
4747
4748    fn local_contiguous_applied_lsn(&self) -> u64 {
4749        match self.inner.db.options().replication.role {
4750            crate::replication::ReplicationRole::Replica { .. } => {
4751                self.config_u64("red.replication.last_applied_lsn", 0)
4752            }
4753            _ => self.cdc_current_lsn(),
4754        }
4755    }
4756
4757    fn execute_vcs_command(
4758        &self,
4759        query: &str,
4760        mode: QueryMode,
4761        cmd: RuntimeVcsCommand,
4762    ) -> RedDBResult<RuntimeQueryResult> {
4763        use crate::application::vcs::{
4764            Author, CheckoutInput, CheckoutTarget, CreateCommitInput, MergeInput, MergeOpts,
4765            ResetInput, ResetMode,
4766        };
4767        use crate::application::VcsUseCases;
4768
4769        let conn_id = current_connection_id();
4770        let vcs = VcsUseCases::new(self);
4771        let default_author = || Author {
4772            name: "rql".to_string(),
4773            email: "rql@reddb.io".to_string(),
4774        };
4775
4776        match cmd {
4777            RuntimeVcsCommand::Checkpoint { message, author } => {
4778                let author = author
4779                    .as_deref()
4780                    .map(parse_vcs_author)
4781                    .unwrap_or_else(default_author);
4782                let commit = vcs.commit(CreateCommitInput {
4783                    connection_id: conn_id,
4784                    message: message.clone(),
4785                    author,
4786                    committer: None,
4787                    amend: false,
4788                    allow_empty: true,
4789                })?;
4790                Ok(RuntimeQueryResult::ok_message(
4791                    query.to_string(),
4792                    &format!("checkpoint {}", commit.hash),
4793                    "vcs_checkpoint",
4794                ))
4795            }
4796            RuntimeVcsCommand::Checkout { target } => {
4797                let result = if target.starts_with("refs/tags/") {
4798                    vcs.checkout(CheckoutInput {
4799                        connection_id: conn_id,
4800                        target: CheckoutTarget::Tag(target.clone()),
4801                        force: false,
4802                    })
4803                } else if looks_like_commit_hash(&target) {
4804                    vcs.checkout(CheckoutInput {
4805                        connection_id: conn_id,
4806                        target: CheckoutTarget::Commit(target.clone()),
4807                        force: false,
4808                    })
4809                } else {
4810                    vcs.checkout(CheckoutInput {
4811                        connection_id: conn_id,
4812                        target: CheckoutTarget::Branch(target.clone()),
4813                        force: false,
4814                    })
4815                    .or_else(|_| {
4816                        vcs.checkout(CheckoutInput {
4817                            connection_id: conn_id,
4818                            target: CheckoutTarget::Commit(target.clone()),
4819                            force: false,
4820                        })
4821                    })
4822                }?;
4823                Ok(RuntimeQueryResult::ok_message(
4824                    query.to_string(),
4825                    &format!("checked out {}", result.name),
4826                    "vcs_checkout",
4827                ))
4828            }
4829            RuntimeVcsCommand::Reset {
4830                mode: reset_mode,
4831                target,
4832            } => {
4833                let mode = match reset_mode {
4834                    RuntimeVcsResetMode::Hard => ResetMode::Hard,
4835                    RuntimeVcsResetMode::Soft => ResetMode::Soft,
4836                    RuntimeVcsResetMode::Mixed => ResetMode::Mixed,
4837                };
4838                vcs.reset(ResetInput {
4839                    connection_id: conn_id,
4840                    target: target.clone(),
4841                    mode,
4842                })?;
4843                Ok(RuntimeQueryResult::ok_message(
4844                    query.to_string(),
4845                    "reset complete",
4846                    "vcs_reset",
4847                ))
4848            }
4849            RuntimeVcsCommand::Merge { branch } => {
4850                let outcome = vcs.merge(MergeInput {
4851                    connection_id: conn_id,
4852                    from: branch.clone(),
4853                    opts: MergeOpts::default(),
4854                    author: default_author(),
4855                })?;
4856                Ok(RuntimeQueryResult::ok_message(
4857                    query.to_string(),
4858                    &format!("merge complete; conflicts={}", outcome.conflicts.len()),
4859                    "vcs_merge",
4860                ))
4861            }
4862            RuntimeVcsCommand::CherryPick { commit } => {
4863                let outcome = vcs.cherry_pick(conn_id, &commit, default_author())?;
4864                Ok(RuntimeQueryResult::ok_message(
4865                    query.to_string(),
4866                    &format!(
4867                        "cherry-pick complete; conflicts={}",
4868                        outcome.conflicts.len()
4869                    ),
4870                    "vcs_cherry_pick",
4871                ))
4872            }
4873            RuntimeVcsCommand::Revert { commit } => {
4874                let reverted = vcs.revert(conn_id, &commit, default_author())?;
4875                Ok(RuntimeQueryResult::ok_message(
4876                    query.to_string(),
4877                    &format!("revert {}", reverted.hash),
4878                    "vcs_revert",
4879                ))
4880            }
4881            RuntimeVcsCommand::ResolveConflict { key, resolution } => {
4882                let value = match resolution {
4883                    RuntimeVcsConflictResolution::Ours => {
4884                        crate::json::Value::String("ours".to_string())
4885                    }
4886                    RuntimeVcsConflictResolution::Theirs => {
4887                        crate::json::Value::String("theirs".to_string())
4888                    }
4889                };
4890                vcs.conflict_resolve(&key, value)?;
4891                Ok(RuntimeQueryResult::ok_message(
4892                    query.to_string(),
4893                    "conflict resolved",
4894                    "vcs_resolve_conflict",
4895                ))
4896            }
4897        }
4898    }
4899
4900    #[inline(never)]
4901    fn execute_query_inner(&self, query: &str) -> RedDBResult<RuntimeQueryResult> {
4902        // ── ULTRA-TURBO: autocommit `SELECT * FROM t WHERE _entity_id = N` ──
4903        //
4904        // Moved above every boot-cost the normal path pays (WITHIN
4905        // strip, SET LOCAL parse, tx_local_tenants read, snapshot
4906        // guard, tracing span, tx_contexts read) because the bench's
4907        // `select_point` scenario was observed at 28× vs PostgreSQL —
4908        // the dominant cost wasn't the entity fetch but the ceremony
4909        // before it. Only fires when there's no ambient transaction
4910        // context or WITHIN override, so the snapshot install we skip
4911        // truly is a no-op for this query.
4912        if !has_scope_override_active()
4913            && !query.trim_start().starts_with("WITHIN")
4914            && !query.trim_start().starts_with("within")
4915            && !self.inner.query_audit.has_rules()
4916            && !self
4917                .inner
4918                .tx_contexts
4919                .read()
4920                .contains_key(&current_connection_id())
4921        {
4922            if let Some(result) = self.try_fast_entity_lookup(query) {
4923                return result;
4924            }
4925        }
4926
4927        // `WITHIN TENANT '<id>' [USER '<u>'] [AS ROLE '<r>'] <stmt>` —
4928        // strip the prefix, push a stack-scoped override, recurse on
4929        // the inner statement, pop on return. Stack lives in a
4930        // thread-local but is balanced by the RAII guard, so a
4931        // pool-shared connection cannot leak the override across
4932        // requests and an early `?` return still pops cleanly.
4933        match crate::runtime::within_clause::try_strip_within_prefix(query) {
4934            Ok(Some((scope, inner))) => {
4935                let _scope_guard = ScopeOverrideGuard::install(scope);
4936                // Re-enter the inner path, NOT `execute_query`, so the
4937                // slow-query lifecycle hook records exactly one row per
4938                // top-level statement (the WITHIN-stripped form would
4939                // double-record).
4940                return self.execute_query_inner(inner);
4941            }
4942            Ok(None) => {}
4943            Err(msg) => return Err(RedDBError::Query(msg)),
4944        }
4945
4946        // `EXPLAIN <stmt>` — introspection. Runs the planner on the
4947        // inner statement (WITHOUT executing it) and returns the
4948        // CanonicalLogicalNode tree as rows so the caller can see the
4949        // operator shape and estimated cost. `EXPLAIN ALTER FOR ...`
4950        // is a distinct schema-diff command and continues down the
4951        // regular SQL path.
4952        if let Some(inner) = strip_explain_prefix(query) {
4953            return self.explain_as_rows(query, inner);
4954        }
4955
4956        // `SET LOCAL TENANT '<id>'` — write the per-transaction tenant
4957        // override and return. Outside a transaction the statement is
4958        // an error (matches PG semantics: SET LOCAL only takes effect
4959        // within an active transaction).
4960        if let Some(value) = parse_set_local_tenant(query)? {
4961            let conn_id = current_connection_id();
4962            if !self.inner.tx_contexts.read().contains_key(&conn_id) {
4963                return Err(RedDBError::Query(
4964                    "SET LOCAL TENANT requires an active transaction".to_string(),
4965                ));
4966            }
4967            self.inner
4968                .tx_local_tenants
4969                .write()
4970                .insert(conn_id, value.clone());
4971            return Ok(RuntimeQueryResult::ok_message(
4972                query.to_string(),
4973                &match &value {
4974                    Some(id) => format!("local tenant set: {id}"),
4975                    None => "local tenant cleared".to_string(),
4976                },
4977                "set_local_tenant",
4978            ));
4979        }
4980
4981        if super::red_schema::is_system_schema_write(query) {
4982            return Err(RedDBError::Query(
4983                super::red_schema::READ_ONLY_ERROR.to_string(),
4984            ));
4985        }
4986
4987        if let Some(command) = parse_runtime_vcs_command(query) {
4988            return self.execute_vcs_command(query, detect_mode(query), command?);
4989        }
4990
4991        if let Some(create_source) = super::analytics_source_catalog::parse_create_statement(query)?
4992        {
4993            return self.execute_create_analytics_source(query, create_source);
4994        }
4995
4996        // Issue #790 — `READ METRIC <path>` is intentionally rejected at
4997        // v0. The descriptor itself is readable through
4998        // `red.analytics.metrics`; the *output* read returns a
4999        // structured error so callers can tell "execution engine not yet
5000        // built" apart from "metric does not exist".
5001        if let Some(path) = super::metric_descriptor_catalog::parse_read_metric_statement(query) {
5002            return Err(super::metric_descriptor_catalog::read_output_unsupported(
5003                &path,
5004            ));
5005        }
5006
5007        // Issue #918 / ADR 0035 — leaderboard rank capability catalog
5008        // declarations are still recognised before the general parser.
5009        // Rank reads themselves are parser AST nodes, including Redis-flavor
5010        // Z* sugar that desugars to the same canonical rank shapes.
5011        if let Some(parsed) = super::ranking_descriptor_catalog::parse_create_ranking(query) {
5012            return self.execute_create_ranking(query, parsed?);
5013        }
5014        if super::ranking_descriptor_catalog::parse_show_rankings(query) {
5015            return self.execute_show_rankings(query);
5016        }
5017
5018        let rewritten_query = super::red_schema::rewrite_virtual_names(query);
5019        let execution_query = rewritten_query.as_deref().unwrap_or(query);
5020
5021        let frame = super::statement_frame::StatementExecutionFrame::build(self, execution_query)?;
5022        let _frame_guards = frame.install(self);
5023
5024        // Phase 6 logging: enter a span stamped with conn_id / tenant
5025        // / query_len. Every downstream tracing::info!/warn!/error!
5026        // inherits these fields — no need to thread them manually
5027        // through storage/scan layers. Entered AFTER the WITHIN /
5028        // SET LOCAL TENANT resolution above so the span reflects the
5029        // effective scope for this statement.
5030        let _log_span = crate::telemetry::span::query_span(query).entered();
5031
5032        // ── CTE prelude (#41) — `WITH x AS (...) SELECT ... FROM x` ──
5033        if let Some(rewritten) = frame.prepare_cte(execution_query)? {
5034            return self.execute_query_expr(rewritten);
5035        }
5036
5037        // ── TURBO: bypass SQL parse for SELECT * FROM x WHERE _entity_id = N ──
5038        if !self.inner.query_audit.has_rules() {
5039            if let Some(result) = self.try_fast_entity_lookup(execution_query) {
5040                return result;
5041            }
5042        }
5043
5044        // ── Result cache: return cached result if still fresh (30s TTL) ──
5045        if !self.inner.query_audit.has_rules() {
5046            if let Some(result) = frame.read_result_cache(self) {
5047                return Ok(result);
5048            }
5049        }
5050
5051        let prepared = frame.prepare_statement(self, execution_query)?;
5052        let mode = prepared.mode;
5053        let expr = prepared.expr;
5054
5055        let statement = query_expr_name(&expr);
5056        let result_cache_scopes = query_expr_result_cache_scopes(&expr);
5057        let control_event_specs = query_control_event_specs(&expr);
5058        let query_audit_plan = query_audit_plan(&expr);
5059
5060        let _lock_guard = match frame.prepare_dispatch(self, &expr) {
5061            Ok(guard) => guard,
5062            Err(err) => {
5063                let outcome = control_event_outcome_for_error(&err);
5064                for spec in &control_event_specs {
5065                    self.emit_control_event(
5066                        spec.kind,
5067                        outcome,
5068                        spec.action,
5069                        spec.resource.clone(),
5070                        Some(err.to_string()),
5071                        spec.fields.clone(),
5072                    )?;
5073                }
5074                return Err(err);
5075            }
5076        };
5077        let frame_iface: &dyn super::statement_frame::ReadFrame = &frame;
5078        let query_audit_started = std::time::Instant::now();
5079
5080        let query_result = match expr {
5081            QueryExpr::Graph(_) | QueryExpr::Path(_) => {
5082                // Apply MVCC visibility + RLS gate while materialising the
5083                // graph: every node entity is screened against the source
5084                // collection's policy chain (basic and `Nodes`-targeted)
5085                // and dropped when the caller's tenant / role doesn't
5086                // admit it. Edges are pruned automatically because the
5087                // graph builder skips edges whose endpoints aren't in
5088                // `allowed_nodes`.
5089                let (graph, node_properties, edge_properties) =
5090                    self.materialize_graph_with_rls()?;
5091                let result =
5092                    crate::storage::query::unified::UnifiedExecutor::execute_on_with_graph_properties(
5093                        &graph,
5094                        &expr,
5095                        node_properties,
5096                        edge_properties,
5097                    )
5098                        .map_err(|err| RedDBError::Query(err.to_string()))?;
5099
5100                Ok(RuntimeQueryResult {
5101                    query: query.to_string(),
5102                    mode,
5103                    statement,
5104                    engine: "materialized-graph",
5105                    result,
5106                    affected_rows: 0,
5107                    statement_type: "select",
5108                    bookmark: None,
5109                })
5110            }
5111            QueryExpr::Table(table) => {
5112                let table = self.resolve_table_expr_subqueries(
5113                    table,
5114                    &frame as &dyn super::statement_frame::ReadFrame,
5115                )?;
5116                // Table-valued functions (e.g. components(g)) dispatch to a
5117                // read-only executor before any catalog/virtual-table routing
5118                // (issue #795).
5119                if let Some(TableSource::Function {
5120                    name,
5121                    args,
5122                    named_args,
5123                }) = table.source.clone()
5124                {
5125                    // The graph-collection form is cacheable (issue #802): the
5126                    // result-cache read at the top of this function keys on the
5127                    // query string, and `result_cache_scopes` carries the graph
5128                    // collection (see `collect_table_source_scopes`) so a write
5129                    // to it invalidates the entry. Deterministic algorithm
5130                    // output is worth caching at any row count, so the write
5131                    // bypasses the generic ≤5-row payload heuristic.
5132                    let tvf_result = RuntimeQueryResult {
5133                        query: query.to_string(),
5134                        mode,
5135                        statement,
5136                        engine: "runtime-graph-tvf",
5137                        result: self.execute_table_function(&name, &args, &named_args)?,
5138                        affected_rows: 0,
5139                        statement_type: "select",
5140                        bookmark: None,
5141                    };
5142                    frame.write_result_cache(self, &tvf_result, result_cache_scopes.clone());
5143                    return Ok(tvf_result);
5144                }
5145                // Inline-graph TVF (issue #799): the graph is supplied by two
5146                // subqueries instead of a collection reference. Unlike the
5147                // graph-collection form, the result IS cacheable — its cache
5148                // key is the query string (the result-cache read at the top of
5149                // `execute_query_inner` keys on it) and `result_cache_scopes`
5150                // already carries the `nodes`/`edges` source collections, so a
5151                // write to any of them invalidates the entry.
5152                if let Some(TableSource::InlineGraphFunction {
5153                    name,
5154                    nodes,
5155                    edges,
5156                    named_args,
5157                }) = table.source.clone()
5158                {
5159                    let inline_result = RuntimeQueryResult {
5160                        query: query.to_string(),
5161                        mode,
5162                        statement,
5163                        engine: "runtime-graph-tvf-inline",
5164                        result: self.execute_inline_graph_function(
5165                            &name,
5166                            &nodes,
5167                            &edges,
5168                            &named_args,
5169                        )?,
5170                        affected_rows: 0,
5171                        statement_type: "select",
5172                        bookmark: None,
5173                    };
5174                    frame.write_result_cache(self, &inline_result, result_cache_scopes);
5175                    return Ok(inline_result);
5176                }
5177                if super::red_schema::is_virtual_table(&table.table) {
5178                    return Ok(RuntimeQueryResult {
5179                        query: query.to_string(),
5180                        mode,
5181                        statement,
5182                        engine: "runtime-red-schema",
5183                        result: super::red_schema::red_query(
5184                            self,
5185                            &table.table,
5186                            &table,
5187                            &frame as &dyn super::statement_frame::ReadFrame,
5188                        )?,
5189                        affected_rows: 0,
5190                        statement_type: "select",
5191                        bookmark: None,
5192                    });
5193                }
5194
5195                // `<graph>.<output>` analytics virtual view (issue #800).
5196                // Recomputed on demand — intentionally not result-cached, so it
5197                // always reflects the current graph data.
5198                if let Some(view_result) = self.try_resolve_analytics_view(
5199                    &table,
5200                    &frame as &dyn super::statement_frame::ReadFrame,
5201                )? {
5202                    return Ok(RuntimeQueryResult {
5203                        query: query.to_string(),
5204                        mode,
5205                        statement,
5206                        engine: "runtime-graph-analytics-view",
5207                        result: view_result,
5208                        affected_rows: 0,
5209                        statement_type: "select",
5210                        bookmark: None,
5211                    });
5212                }
5213
5214                if let Some(result) = self.execute_probabilistic_select(&table)? {
5215                    return Ok(RuntimeQueryResult {
5216                        query: query.to_string(),
5217                        mode,
5218                        statement,
5219                        engine: "runtime-probabilistic",
5220                        result,
5221                        affected_rows: 0,
5222                        statement_type: "select",
5223                        bookmark: None,
5224                    });
5225                }
5226
5227                // Foreign-table intercept (Phase 3.2.2 PG parity).
5228                //
5229                // When the referenced table matches a `CREATE FOREIGN TABLE`
5230                // registration, short-circuit into the FDW scan. Phase 3.2
5231                // wrappers don't yet support pushdown, so filters/projections
5232                // apply post-scan via `apply_foreign_table_filters` — good
5233                // enough for correctness; perf work lands in 3.2.3.
5234                if self.inner.foreign_tables.is_foreign_table(&table.table) {
5235                    let records = self
5236                        .inner
5237                        .foreign_tables
5238                        .scan(&table.table)
5239                        .map_err(|e| RedDBError::Internal(e.to_string()))?;
5240                    let result = apply_foreign_table_filters(records, &table);
5241                    return Ok(RuntimeQueryResult {
5242                        query: query.to_string(),
5243                        mode,
5244                        statement,
5245                        engine: "runtime-fdw",
5246                        result,
5247                        affected_rows: 0,
5248                        statement_type: "select",
5249                        bookmark: None,
5250                    });
5251                }
5252
5253                // Row-Level Security enforcement (Phase 2.5.2 PG parity).
5254                //
5255                // When RLS is enabled on this table, fetch every policy
5256                // that applies to the current (role, SELECT) pair and
5257                // fold them into the query's WHERE clause: policies
5258                // OR-combine (any of them admitting the row is enough),
5259                // then AND into the caller's existing filter.
5260                //
5261                // Anonymous callers (no thread-local identity) pass
5262                // `role = None`; policies with a specific `TO role`
5263                // clause skip, but `TO PUBLIC` policies still apply.
5264                //
5265                // When `inject_rls_filters` returns `None` the table has
5266                // RLS enabled but no policy admits the caller's role —
5267                // short-circuit with an empty result set instead of
5268                // synthesising a contradiction filter.
5269                let Some(table_with_rls) = self.authorize_relational_table_select(
5270                    table,
5271                    &frame as &dyn super::statement_frame::ReadFrame,
5272                )?
5273                else {
5274                    let empty = crate::storage::query::unified::UnifiedResult::empty();
5275                    return Ok(RuntimeQueryResult {
5276                        query: query.to_string(),
5277                        mode,
5278                        statement,
5279                        engine: "runtime-table-rls",
5280                        result: empty,
5281                        affected_rows: 0,
5282                        statement_type: "select",
5283                        bookmark: None,
5284                    });
5285                };
5286                Ok(RuntimeQueryResult {
5287                    query: query.to_string(),
5288                    mode,
5289                    statement,
5290                    engine: "runtime-table",
5291                    // #885: lend the frame-owned row-buffer arena to the
5292                    // streaming path so chunk buffers are reused across
5293                    // this statement's chunk-fetches instead of allocated
5294                    // fresh per chunk. This is the table-query dispatch
5295                    // that runs under a `StatementExecutionFrame`; the
5296                    // frameless prepared/subquery paths keep `None`.
5297                    result: execute_runtime_table_query_in(
5298                        &self.inner.db,
5299                        &table_with_rls,
5300                        Some(&self.inner.index_store),
5301                        Some(frame.row_arena()),
5302                    )?,
5303                    affected_rows: 0,
5304                    statement_type: "select",
5305                    bookmark: None,
5306                })
5307            }
5308            QueryExpr::Join(join) => {
5309                // Fold per-table RLS filters into each `QueryExpr::Table`
5310                // leaf of the join tree before executing. Without this
5311                // the join executor scans both tables raw and ignores
5312                // policies — a `WITHIN TENANT 'x'` against a join of
5313                // two tenant-scoped tables would leak cross-tenant rows.
5314                // When any leaf has RLS enabled and zero matching policy,
5315                // short-circuit to an empty join result instead of
5316                // emitting a contradiction filter.
5317                let join_with_rls = match self.authorize_relational_join_select(
5318                    join,
5319                    &frame as &dyn super::statement_frame::ReadFrame,
5320                )? {
5321                    Some(j) => j,
5322                    None => {
5323                        return Ok(RuntimeQueryResult {
5324                            query: query.to_string(),
5325                            mode,
5326                            statement,
5327                            engine: "runtime-join-rls",
5328                            result: crate::storage::query::unified::UnifiedResult::empty(),
5329                            affected_rows: 0,
5330                            statement_type: "select",
5331                            bookmark: None,
5332                        });
5333                    }
5334                };
5335                Ok(RuntimeQueryResult {
5336                    query: query.to_string(),
5337                    mode,
5338                    statement,
5339                    engine: "runtime-join",
5340                    result: execute_runtime_join_query(&self.inner.db, &join_with_rls)?,
5341                    affected_rows: 0,
5342                    statement_type: "select",
5343                    bookmark: None,
5344                })
5345            }
5346            QueryExpr::Vector(vector) => Ok(RuntimeQueryResult {
5347                query: query.to_string(),
5348                mode,
5349                statement,
5350                engine: "runtime-vector",
5351                result: execute_runtime_vector_query(&self.inner.db, &vector)?,
5352                affected_rows: 0,
5353                statement_type: "select",
5354                bookmark: None,
5355            }),
5356            QueryExpr::Hybrid(hybrid) => Ok(RuntimeQueryResult {
5357                query: query.to_string(),
5358                mode,
5359                statement,
5360                engine: "runtime-hybrid",
5361                result: execute_runtime_hybrid_query(&self.inner.db, &hybrid)?,
5362                affected_rows: 0,
5363                statement_type: "select",
5364                bookmark: None,
5365            }),
5366            QueryExpr::RankOf(ref rank) => self.execute_rank_of(query, rank),
5367            QueryExpr::ApproxRankOf(ref rank) => self.execute_approx_rank_of(query, rank),
5368            QueryExpr::RankRange(ref range) => self.execute_rank_range(query, range),
5369            // DML execution
5370            QueryExpr::Insert(ref insert) if super::red_schema::is_virtual_table(&insert.table) => {
5371                Err(RedDBError::Query(
5372                    super::red_schema::READ_ONLY_ERROR.to_string(),
5373                ))
5374            }
5375            QueryExpr::Update(ref update) if super::red_schema::is_virtual_table(&update.table) => {
5376                Err(RedDBError::Query(
5377                    super::red_schema::READ_ONLY_ERROR.to_string(),
5378                ))
5379            }
5380            QueryExpr::Delete(ref delete) if super::red_schema::is_virtual_table(&delete.table) => {
5381                Err(RedDBError::Query(
5382                    super::red_schema::READ_ONLY_ERROR.to_string(),
5383                ))
5384            }
5385            QueryExpr::Insert(ref insert) => self
5386                .with_deferred_store_wal_for_dml(self.insert_may_emit_events(insert), || {
5387                    self.execute_insert(query, insert)
5388                }),
5389            QueryExpr::Update(ref update) => self
5390                .with_deferred_store_wal_for_dml(self.update_may_emit_events(update), || {
5391                    self.execute_update(query, update)
5392                }),
5393            QueryExpr::Delete(ref delete) => self
5394                .with_deferred_store_wal_for_dml(self.delete_may_emit_events(delete), || {
5395                    self.execute_delete(query, delete)
5396                }),
5397            // DDL execution
5398            QueryExpr::CreateTable(ref create) => self.execute_create_table(query, create),
5399            QueryExpr::CreateCollection(ref create) => {
5400                self.execute_create_collection(query, create)
5401            }
5402            QueryExpr::CreateVector(ref create) => self.execute_create_vector(query, create),
5403            QueryExpr::DropTable(ref drop_tbl) => self.execute_drop_table(query, drop_tbl),
5404            QueryExpr::DropGraph(ref drop_graph) => self.execute_drop_graph(query, drop_graph),
5405            QueryExpr::DropVector(ref drop_vector) => self.execute_drop_vector(query, drop_vector),
5406            QueryExpr::DropDocument(ref drop_document) => {
5407                self.execute_drop_document(query, drop_document)
5408            }
5409            QueryExpr::DropKv(ref drop_kv) => self.execute_drop_kv(query, drop_kv),
5410            QueryExpr::DropCollection(ref drop_collection) => {
5411                self.execute_drop_collection(query, drop_collection)
5412            }
5413            QueryExpr::Truncate(ref truncate) => self.execute_truncate(query, truncate),
5414            QueryExpr::AlterTable(ref alter) => self.execute_alter_table(query, alter),
5415            QueryExpr::CreateVcsRef(ref create) => self.execute_create_vcs_ref(query, create),
5416            QueryExpr::DropVcsRef(ref drop_ref) => self.execute_drop_vcs_ref(query, drop_ref),
5417            QueryExpr::ExplainAlter(ref explain) => self.execute_explain_alter(query, explain),
5418            // Graph analytics commands
5419            QueryExpr::GraphCommand(ref cmd) => self.execute_graph_command(query, cmd),
5420            // Search commands
5421            QueryExpr::SearchCommand(ref cmd) => self.execute_search_command(query, cmd),
5422            // ASK: RAG query with LLM synthesis
5423            QueryExpr::Ask(ref ask) => self.execute_ask(query, ask),
5424            QueryExpr::CreateIndex(ref create_idx) => self.execute_create_index(query, create_idx),
5425            QueryExpr::DropIndex(ref drop_idx) => self.execute_drop_index(query, drop_idx),
5426            QueryExpr::ProbabilisticCommand(ref cmd) => {
5427                self.execute_probabilistic_command(query, cmd)
5428            }
5429            // Time-series DDL
5430            QueryExpr::CreateTimeSeries(ref ts) => self.execute_create_timeseries(query, ts),
5431            QueryExpr::CreateMetric(ref metric) => self.execute_create_metric(query, metric),
5432            QueryExpr::AlterMetric(ref alter) => self.execute_alter_metric(query, alter),
5433            QueryExpr::CreateSlo(ref slo) => self.execute_create_slo(query, slo),
5434            QueryExpr::DropTimeSeries(ref ts) => self.execute_drop_timeseries(query, ts),
5435            // Queue DDL and commands
5436            QueryExpr::CreateQueue(ref q) => self.execute_create_queue(query, q),
5437            QueryExpr::AlterQueue(ref q) => self.execute_alter_queue(query, q),
5438            QueryExpr::DropQueue(ref q) => self.execute_drop_queue(query, q),
5439            QueryExpr::QueueSelect(ref q) => self.execute_queue_select(query, q),
5440            QueryExpr::QueueCommand(ref cmd) => self
5441                .with_deferred_store_wal_if_transaction(|| self.execute_queue_command(query, cmd)),
5442            QueryExpr::EventsBackfill(ref backfill) => {
5443                self.execute_events_backfill(query, backfill)
5444            }
5445            QueryExpr::EventsBackfillStatus { ref collection } => Err(RedDBError::Query(format!(
5446                "EVENTS BACKFILL STATUS for '{collection}' is not implemented in this slice"
5447            ))),
5448            QueryExpr::KvCommand(ref cmd) => self.execute_kv_command(query, cmd),
5449            QueryExpr::ConfigCommand(ref cmd) => self.execute_config_command(query, cmd),
5450            QueryExpr::CreateTree(ref tree) => self.execute_create_tree(query, tree),
5451            QueryExpr::DropTree(ref tree) => self.execute_drop_tree(query, tree),
5452            QueryExpr::TreeCommand(ref cmd) => self.execute_tree_command(query, cmd),
5453            // SET CONFIG key = value
5454            QueryExpr::SetConfig { ref key, ref value } => {
5455                if key.starts_with("red.secret.") {
5456                    return Err(RedDBError::Query(
5457                        "red.secret.* is reserved for vault secrets; use SET SECRET".to_string(),
5458                    ));
5459                }
5460                if key.starts_with("red.secrets.") {
5461                    return Err(RedDBError::Query(
5462                        "red.secrets.* is reserved for vault secrets; use SET SECRET".to_string(),
5463                    ));
5464                }
5465                match self.check_managed_config_write_for_set_config(key) {
5466                    Err(err) => Err(err),
5467                    Ok(()) => {
5468                        let store = self.inner.db.store();
5469                        let json_val = match value {
5470                            Value::Text(s) => crate::serde_json::Value::String(s.to_string()),
5471                            Value::Integer(n) => crate::serde_json::Value::Number(*n as f64),
5472                            Value::Float(n) => crate::serde_json::Value::Number(*n),
5473                            Value::Boolean(b) => crate::serde_json::Value::Bool(*b),
5474                            _ => crate::serde_json::Value::String(value.to_string()),
5475                        };
5476                        store.set_config_tree(key, &json_val);
5477                        update_current_config_value(key, value.clone());
5478                        // Config changes can flip runtime behavior mid-session
5479                        // (auto_decrypt, auto_encrypt, etc.) — invalidate the
5480                        // result cache so subsequent reads re-execute against
5481                        // the new config.
5482                        self.invalidate_result_cache();
5483                        Ok(RuntimeQueryResult::ok_message(
5484                            query.to_string(),
5485                            &format!("config set: {key}"),
5486                            "set",
5487                        ))
5488                    }
5489                }
5490            }
5491            // SET SECRET key = value
5492            QueryExpr::SetSecret { ref key, ref value } => {
5493                if key.starts_with("red.config.") {
5494                    return Err(RedDBError::Query(
5495                        "red.config.* is reserved for config; use SET CONFIG".to_string(),
5496                    ));
5497                }
5498                let auth_store = self.inner.auth_store.read().clone().ok_or_else(|| {
5499                    RedDBError::Query("SET SECRET requires an enabled, unsealed vault".to_string())
5500                })?;
5501                if matches!(value, Value::Null) {
5502                    auth_store
5503                        .vault_kv_try_delete(key)
5504                        .map_err(|err| RedDBError::Query(err.to_string()))?;
5505                    update_current_secret_value(key, None);
5506                    self.invalidate_result_cache();
5507                    return Ok(RuntimeQueryResult::ok_message(
5508                        query.to_string(),
5509                        &format!("secret deleted: {key}"),
5510                        "delete_secret",
5511                    ));
5512                }
5513                let value = secret_sql_value_to_string(value)?;
5514                auth_store
5515                    .vault_kv_try_set(key.clone(), value.clone())
5516                    .map_err(|err| RedDBError::Query(err.to_string()))?;
5517                update_current_secret_value(key, Some(value));
5518                self.invalidate_result_cache();
5519                Ok(RuntimeQueryResult::ok_message(
5520                    query.to_string(),
5521                    &format!("secret set: {key}"),
5522                    "set_secret",
5523                ))
5524            }
5525            // DELETE SECRET key
5526            QueryExpr::DeleteSecret { ref key } => {
5527                let auth_store = self.inner.auth_store.read().clone().ok_or_else(|| {
5528                    RedDBError::Query(
5529                        "DELETE SECRET requires an enabled, unsealed vault".to_string(),
5530                    )
5531                })?;
5532                let deleted = auth_store
5533                    .vault_kv_try_delete(key)
5534                    .map_err(|err| RedDBError::Query(err.to_string()))?;
5535                if deleted {
5536                    update_current_secret_value(key, None);
5537                }
5538                self.invalidate_result_cache();
5539                Ok(RuntimeQueryResult::ok_message(
5540                    query.to_string(),
5541                    &format!("secret deleted: {key}"),
5542                    if deleted {
5543                        "delete_secret"
5544                    } else {
5545                        "delete_secret_not_found"
5546                    },
5547                ))
5548            }
5549            // SHOW SECRET[S] [prefix]
5550            QueryExpr::ShowSecrets { ref prefix } => {
5551                let auth_store = self.inner.auth_store.read().clone().ok_or_else(|| {
5552                    RedDBError::Query("SHOW SECRET requires an enabled, unsealed vault".to_string())
5553                })?;
5554                if !auth_store.is_vault_backed() {
5555                    return Err(RedDBError::Query(
5556                        "SHOW SECRET requires an enabled, unsealed vault".to_string(),
5557                    ));
5558                }
5559                let mut keys = auth_store.vault_kv_keys();
5560                keys.sort();
5561                let mut result = UnifiedResult::with_columns(vec![
5562                    "key".into(),
5563                    "value".into(),
5564                    "status".into(),
5565                ]);
5566                for key in keys {
5567                    if let Some(ref pfx) = prefix {
5568                        if !key.starts_with(pfx) {
5569                            continue;
5570                        }
5571                    }
5572                    let mut record = UnifiedRecord::new();
5573                    record.set("key", Value::text(key));
5574                    record.set("value", Value::text("***"));
5575                    record.set("status", Value::text("active"));
5576                    result.push(record);
5577                }
5578                Ok(RuntimeQueryResult {
5579                    query: query.to_string(),
5580                    mode,
5581                    statement: "show_secrets",
5582                    engine: "runtime-secret",
5583                    result,
5584                    affected_rows: 0,
5585                    statement_type: "select",
5586                    bookmark: None,
5587                })
5588            }
5589            // SHOW CONFIG [prefix] [AS JSON|FORMAT JSON]
5590            QueryExpr::ShowConfig {
5591                ref prefix,
5592                as_json,
5593            } => {
5594                let store = self.inner.db.store();
5595                let all_collections = store.list_collections();
5596                if !all_collections.contains(&"red_config".to_string()) {
5597                    if as_json {
5598                        return Ok(show_config_json_result(
5599                            query,
5600                            mode,
5601                            prefix,
5602                            crate::serde_json::Value::Object(crate::serde_json::Map::new()),
5603                        ));
5604                    }
5605                    let result = UnifiedResult::with_columns(vec!["key".into(), "value".into()]);
5606                    return Ok(RuntimeQueryResult {
5607                        query: query.to_string(),
5608                        mode,
5609                        statement: "show_config",
5610                        engine: "runtime-config",
5611                        result,
5612                        affected_rows: 0,
5613                        statement_type: "select",
5614                        bookmark: None,
5615                    });
5616                }
5617                let manager = store
5618                    .get_collection("red_config")
5619                    .ok_or_else(|| RedDBError::NotFound("red_config".to_string()))?;
5620                let entities = manager.query_all(|_| true);
5621                let mut latest = std::collections::BTreeMap::<String, (u64, Value, Value)>::new();
5622                for entity in entities {
5623                    if let EntityData::Row(ref row) = entity.data {
5624                        if let Some(ref named) = row.named {
5625                            let key_val = named.get("key").cloned().unwrap_or(Value::Null);
5626                            let val = named.get("value").cloned().unwrap_or(Value::Null);
5627                            let key_str = match &key_val {
5628                                Value::Text(s) => s.as_ref(),
5629                                _ => continue,
5630                            };
5631                            if let Some(ref pfx) = prefix {
5632                                if !key_str.starts_with(pfx.as_str()) {
5633                                    continue;
5634                                }
5635                            }
5636                            let entity_id = entity.id.raw();
5637                            match latest.get(key_str) {
5638                                Some((prev_id, _, _)) if *prev_id > entity_id => {}
5639                                _ => {
5640                                    latest.insert(key_str.to_string(), (entity_id, key_val, val));
5641                                }
5642                            }
5643                        }
5644                    }
5645                }
5646                if as_json {
5647                    let mut tree = crate::serde_json::Value::Object(crate::serde_json::Map::new());
5648                    for (key, (_, _, val)) in latest {
5649                        let relative = match prefix {
5650                            Some(pfx) if key == *pfx => "",
5651                            Some(pfx) => key
5652                                .strip_prefix(pfx.as_str())
5653                                .and_then(|tail| tail.strip_prefix('.'))
5654                                .unwrap_or(key.as_str()),
5655                            None => key.as_str(),
5656                        };
5657                        insert_config_json_path(
5658                            &mut tree,
5659                            relative,
5660                            crate::presentation::entity_json::storage_value_to_json(&val),
5661                        );
5662                    }
5663                    return Ok(show_config_json_result(query, mode, prefix, tree));
5664                }
5665                let mut result = UnifiedResult::with_columns(vec!["key".into(), "value".into()]);
5666                for (_, key_val, val) in latest.into_values() {
5667                    let mut record = UnifiedRecord::new();
5668                    record.set("key", key_val);
5669                    record.set("value", val);
5670                    result.push(record);
5671                }
5672                Ok(RuntimeQueryResult {
5673                    query: query.to_string(),
5674                    mode,
5675                    statement: "show_config",
5676                    engine: "runtime-config",
5677                    result,
5678                    affected_rows: 0,
5679                    statement_type: "select",
5680                    bookmark: None,
5681                })
5682            }
5683            // Session-local multi-tenancy handle (Phase 2.5.3).
5684            //
5685            // SET TENANT 'id' / SET TENANT NULL / RESET TENANT — writes
5686            // the thread-local; SHOW TENANT returns it. Paired with the
5687            // CURRENT_TENANT() scalar for use in RLS policies.
5688            QueryExpr::SetTenant(ref value) => {
5689                match value {
5690                    Some(id) => set_current_tenant(id.clone()),
5691                    None => clear_current_tenant(),
5692                }
5693                Ok(RuntimeQueryResult::ok_message(
5694                    query.to_string(),
5695                    &match value {
5696                        Some(id) => format!("tenant set: {id}"),
5697                        None => "tenant cleared".to_string(),
5698                    },
5699                    "set_tenant",
5700                ))
5701            }
5702            QueryExpr::ShowTenant => {
5703                let mut result = UnifiedResult::with_columns(vec!["tenant".into()]);
5704                let mut record = UnifiedRecord::new();
5705                record.set(
5706                    "tenant",
5707                    current_tenant().map(Value::text).unwrap_or(Value::Null),
5708                );
5709                result.push(record);
5710                Ok(RuntimeQueryResult {
5711                    query: query.to_string(),
5712                    mode,
5713                    statement: "show_tenant",
5714                    engine: "runtime-tenant",
5715                    result,
5716                    affected_rows: 0,
5717                    statement_type: "select",
5718                    bookmark: None,
5719                })
5720            }
5721            // Transaction control (Phase 2.3 PG parity).
5722            //
5723            // BEGIN allocates a real `Xid` and stores a `TxnContext` keyed by
5724            // the current connection's id. COMMIT/ROLLBACK release it through
5725            // the `SnapshotManager` so future snapshots see the correct set of
5726            // active/aborted transactions.
5727            //
5728            // Tuple stamping (xmin/xmax) and read-path visibility filtering
5729            // land in Phase 2.3.2 — this dispatch only manages the snapshot
5730            // registry. Statements running outside a TxnContext still behave
5731            // as autocommit (xid=0 → visible to every snapshot).
5732            QueryExpr::TransactionControl(ref ctl) => {
5733                use crate::storage::query::ast::TxnControl;
5734                use crate::storage::transaction::snapshot::{TxnContext, Xid};
5735                use crate::storage::transaction::IsolationLevel;
5736
5737                // Phase 2.3 keys transactions by a thread-local connection id.
5738                // The stdio/gRPC paths wire a real per-connection id later;
5739                // for embedded use (one RedDBRuntime per process-ish caller)
5740                // we fall back to a deterministic placeholder.
5741                let conn_id = current_connection_id();
5742
5743                let (kind, msg) = match ctl {
5744                    TxnControl::Begin => {
5745                        let mgr = Arc::clone(&self.inner.snapshot_manager);
5746                        let xid = mgr.begin();
5747                        let snapshot = mgr.snapshot(xid);
5748                        let ctx = TxnContext {
5749                            xid,
5750                            isolation: IsolationLevel::SnapshotIsolation,
5751                            snapshot,
5752                            savepoints: Vec::new(),
5753                            released_sub_xids: Vec::new(),
5754                        };
5755                        self.inner.tx_contexts.write().insert(conn_id, ctx);
5756                        ("begin", format!("BEGIN — xid={xid} (snapshot isolation)"))
5757                    }
5758                    TxnControl::Commit => {
5759                        // SET LOCAL TENANT ends with the transaction.
5760                        self.inner.tx_local_tenants.write().remove(&conn_id);
5761                        let ctx = self.inner.tx_contexts.write().remove(&conn_id);
5762                        match ctx {
5763                            Some(ctx) => {
5764                                let mut own_xids = std::collections::HashSet::new();
5765                                own_xids.insert(ctx.xid);
5766                                for (_, sub) in &ctx.savepoints {
5767                                    own_xids.insert(*sub);
5768                                }
5769                                for sub in &ctx.released_sub_xids {
5770                                    own_xids.insert(*sub);
5771                                }
5772                                if let Err(err) = self.check_table_row_write_conflicts(
5773                                    conn_id,
5774                                    &ctx.snapshot,
5775                                    &own_xids,
5776                                ) {
5777                                    for (_, sub) in &ctx.savepoints {
5778                                        self.inner.snapshot_manager.rollback(*sub);
5779                                    }
5780                                    for sub in &ctx.released_sub_xids {
5781                                        self.inner.snapshot_manager.rollback(*sub);
5782                                    }
5783                                    self.inner.snapshot_manager.rollback(ctx.xid);
5784                                    self.revive_pending_versioned_updates(conn_id);
5785                                    self.revive_pending_tombstones(conn_id);
5786                                    self.discard_pending_kv_watch_events(conn_id);
5787                                    self.discard_pending_queue_wakes(conn_id);
5788                                    self.discard_pending_store_wal_actions(conn_id);
5789                                    self.release_pending_claim_locks(conn_id);
5790                                    return Err(err);
5791                                }
5792                                self.restore_pending_write_stamps(conn_id);
5793                                if let Err(err) = self.flush_pending_store_wal_actions(conn_id) {
5794                                    for (_, sub) in &ctx.savepoints {
5795                                        self.inner.snapshot_manager.rollback(*sub);
5796                                    }
5797                                    for sub in &ctx.released_sub_xids {
5798                                        self.inner.snapshot_manager.rollback(*sub);
5799                                    }
5800                                    self.inner.snapshot_manager.rollback(ctx.xid);
5801                                    self.revive_pending_versioned_updates(conn_id);
5802                                    self.revive_pending_tombstones(conn_id);
5803                                    self.discard_pending_kv_watch_events(conn_id);
5804                                    self.discard_pending_queue_wakes(conn_id);
5805                                    self.release_pending_claim_locks(conn_id);
5806                                    return Err(err);
5807                                }
5808                                // Phase 2.3.2e: commit every open sub-xid
5809                                // so they also become visible. Their
5810                                // work is promoted to the parent txn's
5811                                // result exactly like a RELEASE would
5812                                // have done.
5813                                for (_, sub) in &ctx.savepoints {
5814                                    self.inner.snapshot_manager.commit(*sub);
5815                                }
5816                                for sub in &ctx.released_sub_xids {
5817                                    self.inner.snapshot_manager.commit(*sub);
5818                                }
5819                                self.inner.snapshot_manager.commit(ctx.xid);
5820                                self.finalize_pending_versioned_updates(conn_id);
5821                                self.finalize_pending_tombstones(conn_id);
5822                                self.finalize_pending_kv_watch_events(conn_id);
5823                                self.finalize_pending_queue_wakes(conn_id);
5824                                self.release_pending_claim_locks(conn_id);
5825                                ("commit", format!("COMMIT — xid={} committed", ctx.xid))
5826                            }
5827                            None => (
5828                                "commit",
5829                                "COMMIT outside transaction — no-op (autocommit)".to_string(),
5830                            ),
5831                        }
5832                    }
5833                    TxnControl::Rollback => {
5834                        self.inner.tx_local_tenants.write().remove(&conn_id);
5835                        let ctx = self.inner.tx_contexts.write().remove(&conn_id);
5836                        match ctx {
5837                            Some(ctx) => {
5838                                // Phase 2.3.2e: abort every open sub-xid
5839                                // too so their writes stay hidden.
5840                                for (_, sub) in &ctx.savepoints {
5841                                    self.inner.snapshot_manager.rollback(*sub);
5842                                }
5843                                for sub in &ctx.released_sub_xids {
5844                                    self.inner.snapshot_manager.rollback(*sub);
5845                                }
5846                                self.inner.snapshot_manager.rollback(ctx.xid);
5847                                // Phase 2.3.2b: tuples that the txn had
5848                                // xmax-stamped become live again — wipe xmax
5849                                // back to 0 so later snapshots see them.
5850                                self.revive_pending_versioned_updates(conn_id);
5851                                self.revive_pending_tombstones(conn_id);
5852                                self.discard_pending_kv_watch_events(conn_id);
5853                                self.discard_pending_queue_wakes(conn_id);
5854                                self.discard_pending_store_wal_actions(conn_id);
5855                                self.release_pending_claim_locks(conn_id);
5856                                ("rollback", format!("ROLLBACK — xid={} aborted", ctx.xid))
5857                            }
5858                            None => (
5859                                "rollback",
5860                                "ROLLBACK outside transaction — no-op (autocommit)".to_string(),
5861                            ),
5862                        }
5863                    }
5864                    // Phase 2.3.2e: savepoints map onto sub-xids. Each
5865                    // SAVEPOINT allocates a fresh xid and pushes it
5866                    // onto the per-txn stack so subsequent writes can
5867                    // be selectively rolled back. RELEASE pops without
5868                    // aborting; ROLLBACK TO aborts the sub-xid (and
5869                    // any nested ones) + revives their tombstones.
5870                    TxnControl::Savepoint(name) => {
5871                        let mgr = Arc::clone(&self.inner.snapshot_manager);
5872                        let mut guard = self.inner.tx_contexts.write();
5873                        match guard.get_mut(&conn_id) {
5874                            Some(ctx) => {
5875                                let sub = mgr.begin();
5876                                ctx.savepoints.push((name.clone(), sub));
5877                                ("savepoint", format!("SAVEPOINT {name} — sub_xid={sub}"))
5878                            }
5879                            None => (
5880                                "savepoint",
5881                                "SAVEPOINT outside transaction — no-op".to_string(),
5882                            ),
5883                        }
5884                    }
5885                    TxnControl::ReleaseSavepoint(name) => {
5886                        let mut guard = self.inner.tx_contexts.write();
5887                        match guard.get_mut(&conn_id) {
5888                            Some(ctx) => {
5889                                let pos = ctx
5890                                    .savepoints
5891                                    .iter()
5892                                    .position(|(n, _)| n == name)
5893                                    .ok_or_else(|| {
5894                                        RedDBError::Internal(format!(
5895                                            "savepoint {name} does not exist"
5896                                        ))
5897                                    })?;
5898                                // RELEASE pops the named savepoint and
5899                                // any nested ones. Their sub-xids move
5900                                // to `released_sub_xids` so they commit
5901                                // (or roll back) alongside the parent
5902                                // xid — PG semantics: released
5903                                // savepoints still contribute their
5904                                // work, but their names are gone.
5905                                let released = ctx.savepoints.len() - pos;
5906                                let popped: Vec<Xid> = ctx
5907                                    .savepoints
5908                                    .split_off(pos)
5909                                    .into_iter()
5910                                    .map(|(_, x)| x)
5911                                    .collect();
5912                                ctx.released_sub_xids.extend(popped);
5913                                (
5914                                    "release_savepoint",
5915                                    format!("RELEASE SAVEPOINT {name} — {released} level(s)"),
5916                                )
5917                            }
5918                            None => (
5919                                "release_savepoint",
5920                                "RELEASE outside transaction — no-op".to_string(),
5921                            ),
5922                        }
5923                    }
5924                    TxnControl::RollbackToSavepoint(name) => {
5925                        let mgr = Arc::clone(&self.inner.snapshot_manager);
5926                        // Splice out the savepoint + nested ones under
5927                        // a narrow lock, then run the snapshot-manager
5928                        // + tombstone side-effects without the tx map
5929                        // held so nothing re-enters.
5930                        let drop_result: Option<(Xid, Vec<Xid>)> = {
5931                            let mut guard = self.inner.tx_contexts.write();
5932                            if let Some(ctx) = guard.get_mut(&conn_id) {
5933                                let pos = ctx
5934                                    .savepoints
5935                                    .iter()
5936                                    .position(|(n, _)| n == name)
5937                                    .ok_or_else(|| {
5938                                        RedDBError::Internal(format!(
5939                                            "savepoint {name} does not exist"
5940                                        ))
5941                                    })?;
5942                                let savepoint_xid = ctx.savepoints[pos].1;
5943                                let aborted: Vec<Xid> = ctx
5944                                    .savepoints
5945                                    .split_off(pos)
5946                                    .into_iter()
5947                                    .map(|(_, x)| x)
5948                                    .collect();
5949                                Some((savepoint_xid, aborted))
5950                            } else {
5951                                None
5952                            }
5953                        };
5954
5955                        match drop_result {
5956                            Some((savepoint_xid, aborted)) => {
5957                                for x in &aborted {
5958                                    mgr.rollback(*x);
5959                                }
5960                                let reverted_updates =
5961                                    self.revive_versioned_updates_since(conn_id, savepoint_xid);
5962                                let revived = self.revive_tombstones_since(conn_id, savepoint_xid);
5963                                (
5964                                    "rollback_to_savepoint",
5965                                    format!(
5966                                        "ROLLBACK TO SAVEPOINT {name} — aborted {} sub_xid(s), reverted {reverted_updates} update(s), revived {revived} tombstone(s)",
5967                                        aborted.len(),
5968                                    ),
5969                                )
5970                            }
5971                            None => (
5972                                "rollback_to_savepoint",
5973                                "ROLLBACK TO outside transaction — no-op".to_string(),
5974                            ),
5975                        }
5976                    }
5977                };
5978                Ok(RuntimeQueryResult::ok_message(
5979                    query.to_string(),
5980                    &msg,
5981                    kind,
5982                ))
5983            }
5984            // Schema + Sequence DDL (Phase 1.3 PG parity).
5985            //
5986            // Schemas are lightweight logical namespaces: a CREATE SCHEMA call
5987            // just registers the name in `red_config` under `schema.{name}`.
5988            // Table lookups still happen by collection name; clients using
5989            // `schema.table` qualified names collapse to collection `schema.table`.
5990            //
5991            // Sequences persist a 64-bit counter + metadata (start, increment)
5992            // in `red_config` under `sequence.{name}.*`. Scalar callers
5993            // `nextval('name')` / `currval('name')` arrive with the MVCC phase
5994            // once we have a proper mutating-function dispatch path; for now the
5995            // DDL just establishes the catalog entry so clients don't error.
5996            QueryExpr::CreateSchema(ref q) => {
5997                let store = self.inner.db.store();
5998                let key = format!("schema.{}", q.name);
5999                if store.get_config(&key).is_some() {
6000                    if q.if_not_exists {
6001                        return Ok(RuntimeQueryResult::ok_message(
6002                            query.to_string(),
6003                            &format!("schema {} already exists — skipped", q.name),
6004                            "create_schema",
6005                        ));
6006                    }
6007                    return Err(RedDBError::Internal(format!(
6008                        "schema {} already exists",
6009                        q.name
6010                    )));
6011                }
6012                store.set_config_tree(&key, &crate::serde_json::Value::Bool(true));
6013                Ok(RuntimeQueryResult::ok_message(
6014                    query.to_string(),
6015                    &format!("schema {} created", q.name),
6016                    "create_schema",
6017                ))
6018            }
6019            QueryExpr::DropSchema(ref q) => {
6020                let store = self.inner.db.store();
6021                let key = format!("schema.{}", q.name);
6022                let existed = store.get_config(&key).is_some();
6023                if !existed && !q.if_exists {
6024                    return Err(RedDBError::Internal(format!(
6025                        "schema {} does not exist",
6026                        q.name
6027                    )));
6028                }
6029                // Remove marker from red_config via set to null.
6030                store.set_config_tree(&key, &crate::serde_json::Value::Null);
6031                let suffix = if q.cascade {
6032                    " (CASCADE accepted — tables untouched)"
6033                } else {
6034                    ""
6035                };
6036                Ok(RuntimeQueryResult::ok_message(
6037                    query.to_string(),
6038                    &format!("schema {} dropped{}", q.name, suffix),
6039                    "drop_schema",
6040                ))
6041            }
6042            QueryExpr::CreateSequence(ref q) => {
6043                let store = self.inner.db.store();
6044                let base = format!("sequence.{}", q.name);
6045                let start_key = format!("{base}.start");
6046                let incr_key = format!("{base}.increment");
6047                let curr_key = format!("{base}.current");
6048                if store.get_config(&start_key).is_some() {
6049                    if q.if_not_exists {
6050                        return Ok(RuntimeQueryResult::ok_message(
6051                            query.to_string(),
6052                            &format!("sequence {} already exists — skipped", q.name),
6053                            "create_sequence",
6054                        ));
6055                    }
6056                    return Err(RedDBError::Internal(format!(
6057                        "sequence {} already exists",
6058                        q.name
6059                    )));
6060                }
6061                // Persist start + increment, and set current so the first
6062                // nextval returns `start`.
6063                let initial_current = q.start - q.increment;
6064                store.set_config_tree(
6065                    &start_key,
6066                    &crate::serde_json::Value::Number(q.start as f64),
6067                );
6068                store.set_config_tree(
6069                    &incr_key,
6070                    &crate::serde_json::Value::Number(q.increment as f64),
6071                );
6072                store.set_config_tree(
6073                    &curr_key,
6074                    &crate::serde_json::Value::Number(initial_current as f64),
6075                );
6076                Ok(RuntimeQueryResult::ok_message(
6077                    query.to_string(),
6078                    &format!(
6079                        "sequence {} created (start={}, increment={})",
6080                        q.name, q.start, q.increment
6081                    ),
6082                    "create_sequence",
6083                ))
6084            }
6085            QueryExpr::DropSequence(ref q) => {
6086                let store = self.inner.db.store();
6087                let base = format!("sequence.{}", q.name);
6088                let existed = store.get_config(&format!("{base}.start")).is_some();
6089                if !existed && !q.if_exists {
6090                    return Err(RedDBError::Internal(format!(
6091                        "sequence {} does not exist",
6092                        q.name
6093                    )));
6094                }
6095                for k in ["start", "increment", "current"] {
6096                    store.set_config_tree(&format!("{base}.{k}"), &crate::serde_json::Value::Null);
6097                }
6098                Ok(RuntimeQueryResult::ok_message(
6099                    query.to_string(),
6100                    &format!("sequence {} dropped", q.name),
6101                    "drop_sequence",
6102                ))
6103            }
6104            // Views — CREATE [MATERIALIZED] VIEW (Phase 2.1 PG parity).
6105            //
6106            // The view definition is stored in-memory on RuntimeInner (not
6107            // persisted). SELECTs that reference the view name will substitute
6108            // the stored `QueryExpr` via `resolve_view_reference` during
6109            // planning (same entry point used by table-name resolution).
6110            //
6111            // Materialized views additionally allocate a slot in
6112            // `MaterializedViewCache`; a REFRESH repopulates that slot.
6113            QueryExpr::CreateView(ref q) => {
6114                let mut views = self.inner.views.write();
6115                if views.contains_key(&q.name) && !q.or_replace {
6116                    if q.if_not_exists {
6117                        return Ok(RuntimeQueryResult::ok_message(
6118                            query.to_string(),
6119                            &format!("view {} already exists — skipped", q.name),
6120                            "create_view",
6121                        ));
6122                    }
6123                    return Err(RedDBError::Internal(format!(
6124                        "view {} already exists",
6125                        q.name
6126                    )));
6127                }
6128                views.insert(q.name.clone(), Arc::new(q.clone()));
6129                drop(views);
6130
6131                // Materialized view: register cache slot (data is empty until REFRESH).
6132                if q.materialized {
6133                    use crate::storage::cache::result::{MaterializedViewDef, RefreshPolicy};
6134                    let refresh = match q.refresh_every_ms {
6135                        Some(ms) => RefreshPolicy::Periodic(std::time::Duration::from_millis(ms)),
6136                        None => RefreshPolicy::Manual,
6137                    };
6138                    let dependencies = collect_table_refs(&q.query);
6139                    let def = MaterializedViewDef {
6140                        name: q.name.clone(),
6141                        query: format!("<parsed view {}>", q.name),
6142                        dependencies: dependencies.clone(),
6143                        refresh,
6144                        retention_duration_ms: q.retention_duration_ms,
6145                    };
6146                    self.inner.materialized_views.write().register(def);
6147
6148                    // Issue #593 slice 9a — persist the descriptor to
6149                    // the system catalog so the definition survives a
6150                    // restart. Upsert semantics (delete-then-insert by
6151                    // name) keep the catalog free of duplicate rows
6152                    // across `CREATE OR REPLACE` churn.
6153                    let descriptor =
6154                        crate::runtime::continuous_materialized_view::MaterializedViewDescriptor {
6155                            name: q.name.clone(),
6156                            source_sql: query.to_string(),
6157                            source_collections: dependencies,
6158                            refresh_every_ms: q.refresh_every_ms,
6159                            retention_duration_ms: q.retention_duration_ms,
6160                        };
6161                    let store = self.inner.db.store();
6162                    crate::runtime::continuous_materialized_view::persist_descriptor(
6163                        store.as_ref(),
6164                        &descriptor,
6165                    )?;
6166
6167                    // Issue #594 slice 9b — provision a Table-shaped
6168                    // backing collection named after the view. The
6169                    // rewriter skips materialized views (see
6170                    // `rewrite_view_refs_inner`) so `SELECT FROM v`
6171                    // resolves to this collection directly. Empty
6172                    // until REFRESH wires through it in 9c.
6173                    self.ensure_materialized_view_backing(&q.name)?;
6174                }
6175                // Plan cache may have cached a plan that didn't know about this
6176                // view — invalidate so future references pick up the new binding.
6177                // Result cache gets flushed too: OR REPLACE must not serve a
6178                // prior execution of the obsolete body.
6179                self.invalidate_plan_cache();
6180                self.invalidate_result_cache();
6181
6182                Ok(RuntimeQueryResult::ok_message(
6183                    query.to_string(),
6184                    &format!(
6185                        "{}view {} created",
6186                        if q.materialized { "materialized " } else { "" },
6187                        q.name
6188                    ),
6189                    "create_view",
6190                ))
6191            }
6192            QueryExpr::DropView(ref q) => {
6193                let mut views = self.inner.views.write();
6194                let removed = views.remove(&q.name);
6195                let existed = removed.is_some();
6196                let removed_materialized =
6197                    removed.as_ref().map(|v| v.materialized).unwrap_or(false);
6198                drop(views);
6199                if q.materialized || existed {
6200                    // Try the materialised cache too — silent if absent.
6201                    self.inner.materialized_views.write().remove(&q.name);
6202                    // Issue #593 slice 9a — remove any persisted
6203                    // catalog row. Idempotent: a no-op when the view
6204                    // was never materialized (no row was ever written).
6205                    let store = self.inner.db.store();
6206                    crate::runtime::continuous_materialized_view::remove_by_name(
6207                        store.as_ref(),
6208                        &q.name,
6209                    )?;
6210                }
6211                // Issue #594 slice 9b — drop the backing collection
6212                // that was provisioned at CREATE time. Only mat views
6213                // ever had one; regular views never did.
6214                if removed_materialized || q.materialized {
6215                    self.drop_materialized_view_backing(&q.name)?;
6216                }
6217                // Drop any plan / result cache entries that baked the
6218                // view body into their QueryExpr.
6219                self.invalidate_plan_cache();
6220                self.invalidate_result_cache();
6221                if !existed && !q.if_exists {
6222                    return Err(RedDBError::Internal(format!(
6223                        "view {} does not exist",
6224                        q.name
6225                    )));
6226                }
6227                self.invalidate_plan_cache();
6228                Ok(RuntimeQueryResult::ok_message(
6229                    query.to_string(),
6230                    &format!("view {} dropped", q.name),
6231                    "drop_view",
6232                ))
6233            }
6234            QueryExpr::RefreshMaterializedView(ref q) => {
6235                // Look up the view definition, execute its underlying query,
6236                // and stash the serialized result in the materialised cache.
6237                let view = {
6238                    let views = self.inner.views.read();
6239                    views.get(&q.name).cloned()
6240                };
6241                let view = match view {
6242                    Some(v) => v,
6243                    None => {
6244                        return Err(RedDBError::Internal(format!(
6245                            "view {} does not exist",
6246                            q.name
6247                        )))
6248                    }
6249                };
6250                if !view.materialized {
6251                    return Err(RedDBError::Internal(format!(
6252                        "view {} is not materialized — REFRESH requires \
6253                         CREATE MATERIALIZED VIEW",
6254                        q.name
6255                    )));
6256                }
6257                // Execute the underlying query fresh.
6258                let started = std::time::Instant::now();
6259                let now_ms = std::time::SystemTime::now()
6260                    .duration_since(std::time::UNIX_EPOCH)
6261                    .map(|d| d.as_millis() as u64)
6262                    .unwrap_or(0);
6263                match self.execute_query_expr((*view.query).clone()) {
6264                    Ok(inner_result) => {
6265                        // Issue #595 slice 9c — atomically replace the
6266                        // backing collection's contents under a single
6267                        // WAL group. Concurrent SELECT from the view
6268                        // sees either the prior or new contents, never
6269                        // partial. A crash before the WAL commit lands
6270                        // leaves the prior contents intact on recovery.
6271                        let entities =
6272                            view_records_to_entities(&q.name, &inner_result.result.records);
6273                        let row_count = entities.len() as u64;
6274                        let store = self.inner.db.store();
6275                        let serialized_records = match store.refresh_collection(&q.name, entities) {
6276                            Ok(records) => records,
6277                            Err(err) => {
6278                                let duration_ms = started.elapsed().as_millis() as u64;
6279                                let msg = err.to_string();
6280                                self.inner
6281                                    .materialized_views
6282                                    .write()
6283                                    .record_refresh_failure(
6284                                        &q.name,
6285                                        msg.clone(),
6286                                        duration_ms,
6287                                        now_ms,
6288                                    );
6289                                return Err(RedDBError::Internal(format!(
6290                                    "REFRESH MATERIALIZED VIEW {}: {msg}",
6291                                    q.name
6292                                )));
6293                            }
6294                        };
6295
6296                        // Issue #596 slice 9d — emit a Refresh
6297                        // ChangeRecord into the logical-WAL spool so
6298                        // replicas deterministically replay the same
6299                        // backing-collection contents via
6300                        // `LogicalChangeApplier::apply_record`.
6301                        if let Some(ref primary) = self.inner.db.replication {
6302                            let lsn = self.inner.cdc.emit(
6303                                crate::replication::cdc::ChangeOperation::Refresh,
6304                                &q.name,
6305                                0,
6306                                "refresh",
6307                            );
6308                            self.invalidate_result_cache_for_table(&q.name);
6309                            let timestamp = std::time::SystemTime::now()
6310                                .duration_since(std::time::UNIX_EPOCH)
6311                                .unwrap_or_default()
6312                                .as_millis() as u64;
6313                            let record = ChangeRecord::for_refresh(
6314                                lsn,
6315                                timestamp,
6316                                q.name.clone(),
6317                                serialized_records,
6318                            )
6319                            .with_term(self.current_replication_term());
6320                            let encoded = record.encode();
6321                            primary.append_logical_record(record.lsn, encoded);
6322                        }
6323
6324                        let duration_ms = started.elapsed().as_millis() as u64;
6325                        let serialized = format!("{:?}", inner_result.result);
6326                        self.inner
6327                            .materialized_views
6328                            .write()
6329                            .record_refresh_success(
6330                                &q.name,
6331                                serialized.into_bytes(),
6332                                row_count,
6333                                duration_ms,
6334                                now_ms,
6335                            );
6336                        // SELECT FROM v now reads through the rewriter
6337                        // skip into the backing collection — drop the
6338                        // result cache so prior empty-backing reads
6339                        // don't shadow the new contents.
6340                        self.invalidate_result_cache();
6341                        Ok(RuntimeQueryResult::ok_message(
6342                            query.to_string(),
6343                            &format!("materialized view {} refreshed", q.name),
6344                            "refresh_materialized_view",
6345                        ))
6346                    }
6347                    Err(err) => {
6348                        let duration_ms = started.elapsed().as_millis() as u64;
6349                        let msg = err.to_string();
6350                        self.inner
6351                            .materialized_views
6352                            .write()
6353                            .record_refresh_failure(&q.name, msg.clone(), duration_ms, now_ms);
6354                        Err(err)
6355                    }
6356                }
6357            }
6358            // Row Level Security (Phase 2.5 PG parity).
6359            //
6360            // Policies live in an in-memory registry keyed by (table, name).
6361            // Enforcement (AND-ing the policy's USING clause into every
6362            // query's WHERE for the table) arrives in Phase 2.5.2 via the
6363            // filter compiler; this dispatch only manages the catalog.
6364            QueryExpr::CreatePolicy(ref q) => {
6365                let key = (q.table.clone(), q.name.clone());
6366                self.inner
6367                    .rls_policies
6368                    .write()
6369                    .insert(key, Arc::new(q.clone()));
6370                self.invalidate_plan_cache();
6371                // Issue #120 — surface policy names in the
6372                // schema-vocabulary so AskPipeline (#121) can resolve
6373                // a policy reference back to its table.
6374                self.schema_vocabulary_apply(
6375                    crate::runtime::schema_vocabulary::DdlEvent::CreatePolicy {
6376                        collection: q.table.clone(),
6377                        policy: q.name.clone(),
6378                    },
6379                );
6380                Ok(RuntimeQueryResult::ok_message(
6381                    query.to_string(),
6382                    &format!("policy {} on {} created", q.name, q.table),
6383                    "create_policy",
6384                ))
6385            }
6386            QueryExpr::DropPolicy(ref q) => {
6387                let removed = self
6388                    .inner
6389                    .rls_policies
6390                    .write()
6391                    .remove(&(q.table.clone(), q.name.clone()))
6392                    .is_some();
6393                if !removed && !q.if_exists {
6394                    return Err(RedDBError::Internal(format!(
6395                        "policy {} on {} does not exist",
6396                        q.name, q.table
6397                    )));
6398                }
6399                self.invalidate_plan_cache();
6400                // Issue #120 — keep the schema-vocabulary policy
6401                // entry in sync.
6402                self.schema_vocabulary_apply(
6403                    crate::runtime::schema_vocabulary::DdlEvent::DropPolicy {
6404                        collection: q.table.clone(),
6405                        policy: q.name.clone(),
6406                    },
6407                );
6408                Ok(RuntimeQueryResult::ok_message(
6409                    query.to_string(),
6410                    &format!("policy {} on {} dropped", q.name, q.table),
6411                    "drop_policy",
6412                ))
6413            }
6414            // Foreign Data Wrappers (Phase 3.2 PG parity).
6415            //
6416            // CREATE SERVER / CREATE FOREIGN TABLE register into the shared
6417            // `ForeignTableRegistry`. The read path consults that registry
6418            // before dispatching a SELECT — when the table name matches a
6419            // registered foreign table, we forward the scan to the wrapper
6420            // and skip the normal collection lookup.
6421            //
6422            // Phase 3.2 is in-memory only; persistence across restarts is a
6423            // 3.2.2 follow-up that mirrors the view registry pattern.
6424            QueryExpr::CreateServer(ref q) => {
6425                use crate::storage::fdw::FdwOptions;
6426                let registry = Arc::clone(&self.inner.foreign_tables);
6427                if registry.server(&q.name).is_some() {
6428                    if q.if_not_exists {
6429                        return Ok(RuntimeQueryResult::ok_message(
6430                            query.to_string(),
6431                            &format!("server {} already exists — skipped", q.name),
6432                            "create_server",
6433                        ));
6434                    }
6435                    return Err(RedDBError::Internal(format!(
6436                        "server {} already exists",
6437                        q.name
6438                    )));
6439                }
6440                let mut opts = FdwOptions::new();
6441                for (k, v) in &q.options {
6442                    opts.values.insert(k.clone(), v.clone());
6443                }
6444                registry
6445                    .create_server(&q.name, &q.wrapper, opts)
6446                    .map_err(|e| RedDBError::Internal(e.to_string()))?;
6447                Ok(RuntimeQueryResult::ok_message(
6448                    query.to_string(),
6449                    &format!("server {} created (wrapper {})", q.name, q.wrapper),
6450                    "create_server",
6451                ))
6452            }
6453            QueryExpr::DropServer(ref q) => {
6454                let existed = self.inner.foreign_tables.drop_server(&q.name);
6455                if !existed && !q.if_exists {
6456                    return Err(RedDBError::Internal(format!(
6457                        "server {} does not exist",
6458                        q.name
6459                    )));
6460                }
6461                Ok(RuntimeQueryResult::ok_message(
6462                    query.to_string(),
6463                    &format!(
6464                        "server {} dropped{}",
6465                        q.name,
6466                        if q.cascade { " (cascade)" } else { "" }
6467                    ),
6468                    "drop_server",
6469                ))
6470            }
6471            QueryExpr::CreateForeignTable(ref q) => {
6472                use crate::storage::fdw::{FdwOptions, ForeignColumn, ForeignTable};
6473                let registry = Arc::clone(&self.inner.foreign_tables);
6474                if registry.foreign_table(&q.name).is_some() {
6475                    if q.if_not_exists {
6476                        return Ok(RuntimeQueryResult::ok_message(
6477                            query.to_string(),
6478                            &format!("foreign table {} already exists — skipped", q.name),
6479                            "create_foreign_table",
6480                        ));
6481                    }
6482                    return Err(RedDBError::Internal(format!(
6483                        "foreign table {} already exists",
6484                        q.name
6485                    )));
6486                }
6487                let mut opts = FdwOptions::new();
6488                for (k, v) in &q.options {
6489                    opts.values.insert(k.clone(), v.clone());
6490                }
6491                let columns: Vec<ForeignColumn> = q
6492                    .columns
6493                    .iter()
6494                    .map(|c| ForeignColumn {
6495                        name: c.name.clone(),
6496                        data_type: c.data_type.clone(),
6497                        not_null: c.not_null,
6498                    })
6499                    .collect();
6500                registry
6501                    .create_foreign_table(ForeignTable {
6502                        name: q.name.clone(),
6503                        server_name: q.server.clone(),
6504                        columns,
6505                        options: opts,
6506                    })
6507                    .map_err(|e| RedDBError::Internal(e.to_string()))?;
6508                self.invalidate_plan_cache();
6509                Ok(RuntimeQueryResult::ok_message(
6510                    query.to_string(),
6511                    &format!("foreign table {} created (server {})", q.name, q.server),
6512                    "create_foreign_table",
6513                ))
6514            }
6515            QueryExpr::DropForeignTable(ref q) => {
6516                let existed = self.inner.foreign_tables.drop_foreign_table(&q.name);
6517                if !existed && !q.if_exists {
6518                    return Err(RedDBError::Internal(format!(
6519                        "foreign table {} does not exist",
6520                        q.name
6521                    )));
6522                }
6523                self.invalidate_plan_cache();
6524                Ok(RuntimeQueryResult::ok_message(
6525                    query.to_string(),
6526                    &format!("foreign table {} dropped", q.name),
6527                    "drop_foreign_table",
6528                ))
6529            }
6530            // COPY table FROM 'path' (Phase 1.5 PG parity).
6531            //
6532            // Stream CSV rows through the shared `CsvImporter`. The collection
6533            // is auto-created on first insert (via `insert_auto`-style path);
6534            // VACUUM/ANALYZE afterwards is up to the caller.
6535            QueryExpr::CopyFrom(ref q) => {
6536                use crate::storage::import::{CsvConfig, CsvImporter};
6537                let store = self.inner.db.store();
6538                let cfg = CsvConfig {
6539                    collection: q.table.clone(),
6540                    has_header: q.has_header,
6541                    delimiter: q.delimiter.map(|c| c as u8).unwrap_or(b','),
6542                    ..CsvConfig::default()
6543                };
6544                let importer = CsvImporter::new(cfg);
6545                let stats = importer
6546                    .import_file(&q.path, store.as_ref())
6547                    .map_err(|e| RedDBError::Internal(format!("COPY failed: {e}")))?;
6548                // Tables are written → invalidate cached plans / result cache.
6549                self.note_table_write(&q.table);
6550                Ok(RuntimeQueryResult::ok_message(
6551                    query.to_string(),
6552                    &format!(
6553                        "COPY imported {} rows into {} ({} errors skipped, {}ms)",
6554                        stats.records_imported, q.table, stats.errors_skipped, stats.duration_ms
6555                    ),
6556                    "copy_from",
6557                ))
6558            }
6559            // Maintenance commands (Phase 1.2 PG parity).
6560            //
6561            // - VACUUM [FULL] [table]: refreshes planner stats for the target
6562            //   collection(s) and — when FULL — triggers a full pager persist
6563            //   (flushes dirty pages + fsync). Also invalidates the result cache
6564            //   so subsequent reads re-execute against the freshly compacted
6565            //   storage. RedDB's segment/btree GC runs continuously via the
6566            //   background lifecycle; explicit space reclamation for sealed
6567            //   segments arrives with Phase 2.3 (MVCC + dead-tuple reclamation).
6568            // - ANALYZE [table]: reruns `analyze_collection` +
6569            //   `persist_table_stats` via `refresh_table_planner_stats` so the
6570            //   planner has fresh histograms, distinct estimates, null counts.
6571            //
6572            // Both commands accept an optional target; omitting the target
6573            // iterates every collection in the store.
6574            QueryExpr::MaintenanceCommand(ref cmd) => {
6575                use crate::storage::query::ast::MaintenanceCommand as Mc;
6576                let store = self.inner.db.store();
6577                let (kind, msg) = match cmd {
6578                    Mc::Analyze { target } => {
6579                        let targets: Vec<String> = match target {
6580                            Some(t) => vec![t.clone()],
6581                            None => store.list_collections(),
6582                        };
6583                        for t in &targets {
6584                            self.refresh_table_planner_stats(t);
6585                        }
6586                        (
6587                            "analyze",
6588                            format!("ANALYZE refreshed stats for {} table(s)", targets.len()),
6589                        )
6590                    }
6591                    Mc::Vacuum { target, full } => {
6592                        let targets: Vec<String> = match target {
6593                            Some(t) => vec![t.clone()],
6594                            None => store.list_collections(),
6595                        };
6596                        let cutoff_xid = self.mvcc_vacuum_cutoff_xid();
6597                        let mut vacuum_stats =
6598                            crate::storage::unified::store::MvccVacuumStats::default();
6599                        for t in &targets {
6600                            let stats = store.vacuum_mvcc_history(t, cutoff_xid).map_err(|e| {
6601                                RedDBError::Internal(format!(
6602                                    "VACUUM MVCC history failed for {t}: {e}"
6603                                ))
6604                            })?;
6605                            if stats.reclaimed_versions > 0 {
6606                                self.rebuild_runtime_indexes_for_table(t)?;
6607                            }
6608                            vacuum_stats.add(&stats);
6609                        }
6610                        self.inner.snapshot_manager.prune_aborted(cutoff_xid);
6611                        // Stats refresh covers every target (same as ANALYZE).
6612                        for t in &targets {
6613                            self.refresh_table_planner_stats(t);
6614                        }
6615                        // FULL forces a pager persist (dirty-page flush + fsync).
6616                        // Regular VACUUM relies on the background writer / segment
6617                        // lifecycle so the command is non-blocking.
6618                        let persisted = if *full {
6619                            match store.persist() {
6620                                Ok(()) => true,
6621                                Err(e) => {
6622                                    return Err(RedDBError::Internal(format!(
6623                                        "VACUUM FULL persist failed: {e:?}"
6624                                    )));
6625                                }
6626                            }
6627                        } else {
6628                            false
6629                        };
6630                        // Result cache depended on pre-vacuum state.
6631                        self.invalidate_result_cache();
6632                        (
6633                            "vacuum",
6634                            format!(
6635                                "VACUUM{} processed {} table(s): scanned_versions={}, retained_versions={}, reclaimed_versions={}, retained_history_versions={}, reclaimed_history_versions={}, retained_tombstones={}, reclaimed_tombstones={}{}",
6636                                if *full { " FULL" } else { "" },
6637                                targets.len(),
6638                                vacuum_stats.scanned_versions,
6639                                vacuum_stats.retained_versions,
6640                                vacuum_stats.reclaimed_versions,
6641                                vacuum_stats.retained_history_versions,
6642                                vacuum_stats.reclaimed_history_versions,
6643                                vacuum_stats.retained_tombstones,
6644                                vacuum_stats.reclaimed_tombstones,
6645                                if persisted {
6646                                    " (pages flushed to disk)"
6647                                } else {
6648                                    ""
6649                                }
6650                            ),
6651                        )
6652                    }
6653                };
6654                Ok(RuntimeQueryResult::ok_message(
6655                    query.to_string(),
6656                    &msg,
6657                    kind,
6658                ))
6659            }
6660            // GRANT / REVOKE / ALTER USER (RBAC milestone).
6661            //
6662            // These hit the AuthStore directly. The statement frame /
6663            // privilege gate has already decided whether the caller may
6664            // even run the statement; here we just translate the AST into
6665            // AuthStore calls.
6666            QueryExpr::Grant(ref g) => self.execute_grant_statement(query, g),
6667            QueryExpr::Revoke(ref r) => self.execute_revoke_statement(query, r),
6668            QueryExpr::AlterUser(ref a) => self.execute_alter_user_statement(query, a),
6669            QueryExpr::CreateUser(ref u) => self.execute_create_user_statement(query, u),
6670            QueryExpr::CreateIamPolicy { ref id, ref json } => {
6671                self.execute_create_iam_policy(query, id, json)
6672            }
6673            QueryExpr::DropIamPolicy { ref id } => self.execute_drop_iam_policy(query, id),
6674            QueryExpr::AttachPolicy {
6675                ref policy_id,
6676                ref principal,
6677            } => self.execute_attach_policy(query, policy_id, principal),
6678            QueryExpr::DetachPolicy {
6679                ref policy_id,
6680                ref principal,
6681            } => self.execute_detach_policy(query, policy_id, principal),
6682            QueryExpr::ShowPolicies { ref filter } => {
6683                self.execute_show_policies(query, filter.as_ref())
6684            }
6685            QueryExpr::ShowEffectivePermissions {
6686                ref user,
6687                ref resource,
6688            } => self.execute_show_effective_permissions(query, user, resource.as_ref()),
6689            QueryExpr::SimulatePolicy {
6690                ref user,
6691                ref action,
6692                ref resource,
6693            } => self.execute_simulate_policy(query, user, action, resource),
6694            QueryExpr::LintPolicy { ref source } => self.execute_lint_policy(query, source),
6695            QueryExpr::MigratePolicyMode {
6696                ref target,
6697                dry_run,
6698            } => self.execute_migrate_policy_mode(query, target, dry_run),
6699            QueryExpr::CreateMigration(ref q) => self.execute_create_migration(query, q),
6700            QueryExpr::ApplyMigration(ref q) => self.execute_apply_migration(query, q),
6701            QueryExpr::RollbackMigration(ref q) => self.execute_rollback_migration(query, q),
6702            QueryExpr::ExplainMigration(ref q) => self.execute_explain_migration(query, q),
6703            _ => Err(RedDBError::Query(
6704                "unsupported command in runtime dispatcher".to_string(),
6705            )),
6706        };
6707
6708        if !control_event_specs.is_empty() {
6709            let (outcome, reason) = match &query_result {
6710                Ok(_) => (crate::runtime::control_events::Outcome::Allowed, None),
6711                Err(err) => (control_event_outcome_for_error(err), Some(err.to_string())),
6712            };
6713            for spec in &control_event_specs {
6714                self.emit_control_event(
6715                    spec.kind,
6716                    outcome,
6717                    spec.action,
6718                    spec.resource.clone(),
6719                    reason.clone(),
6720                    spec.fields.clone(),
6721                )?;
6722            }
6723        }
6724
6725        if let (Some(plan), Ok(result)) = (&query_audit_plan, &query_result) {
6726            self.emit_query_audit(
6727                query,
6728                plan,
6729                query_audit_started.elapsed().as_millis() as u64,
6730                result,
6731            );
6732        }
6733
6734        // Decrypt Value::Secret columns in-place before caching, so
6735        // cached results match the post-decrypt shape and repeat
6736        // queries skip the per-row AES-GCM pass.
6737        let mut query_result = query_result;
6738        if let Ok(ref mut result) = query_result {
6739            if result.statement_type == "select" {
6740                self.apply_secret_decryption(result);
6741            }
6742        }
6743
6744        // Cache SELECT results for 30s.
6745        // Skip: pre-serialized JSON (large clone), and result sets > 5 rows.
6746        // Large multi-row results (range scans, filtered scans) are rarely
6747        // repeated with the same literal values so the cache hit rate is near
6748        // zero while the clone cost (100 records × ~16 fields each) is high.
6749        // Aggregations (1 row) and point lookups (1 row) still benefit.
6750        if let Ok(ref result) = query_result {
6751            frame.write_result_cache(self, result, result_cache_scopes);
6752        }
6753
6754        query_result
6755    }
6756
6757    /// Snapshot of every registered materialized view's runtime
6758    /// state — feeds the `red.materialized_views` virtual table.
6759    /// Issue #583 slice 10.
6760    pub fn materialized_view_metadata(
6761        &self,
6762    ) -> Vec<crate::storage::cache::result::MaterializedViewMetadata> {
6763        // Issue #595 slice 9c — `current_row_count` is now scraped
6764        // live from the backing collection rather than read from the
6765        // cache slot. Mirrors the slice-10 invariant on
6766        // `queue_pending_gauge` in #527: the live store is the source
6767        // of truth, the cache slot only carries last-refresh telemetry
6768        // (timing, error, refresh cadence).
6769        let store = self.inner.db.store();
6770        let mut entries = self.inner.materialized_views.read().metadata();
6771        for entry in &mut entries {
6772            if let Some(manager) = store.get_collection(&entry.name) {
6773                entry.current_row_count = manager.count() as u64;
6774            }
6775        }
6776        entries
6777    }
6778
6779    /// Drive scheduled refreshes for materialized views with a
6780    /// `REFRESH EVERY <duration>` clause. Called from the background
6781    /// scheduler thread (and from unit tests with a fake clock via
6782    /// `claim_due_at`). Each invocation atomically claims the set of
6783    /// due views (so two concurrent ticks never double-fire the same
6784    /// view) and runs each refresh through the standard execution
6785    /// path — failures are captured in `last_error` and the prior
6786    /// content stays intact. Issue #583 slice 10.
6787    /// Snapshot of every tracked retention sweeper state — feeds the
6788    /// three extra columns on `red.retention`. Issue #584 slice 12.
6789    pub(crate) fn retention_sweeper_snapshot(
6790        &self,
6791    ) -> Vec<(String, crate::runtime::retention_sweeper::SweeperState)> {
6792        self.inner.retention_sweeper.read().snapshot()
6793    }
6794
6795    /// Drive one tick of the retention sweeper. Iterates collections
6796    /// with a retention policy set, physically deletes at most
6797    /// `batch_size` expired rows per collection, and records the
6798    /// `last_sweep_at_ms` / `rows_swept_total` / pending estimate that
6799    /// `red.retention` exposes. Called from the background sweeper
6800    /// thread; safe to invoke directly from tests with a small batch
6801    /// size to drain rows deterministically. Issue #584 slice 12.
6802    ///
6803    /// Deletes are issued as `DELETE FROM <collection> WHERE
6804    /// <ts_column> < <cutoff>` through the standard `execute_query`
6805    /// chokepoint so WAL participation and snapshot guards apply
6806    /// exactly as for a user-issued DELETE — replicas replay the
6807    /// sweeper's deletes via the same WAL stream with no special
6808    /// handling on the replication side.
6809    ///
6810    /// Batching is enforced by tightening the cutoff: if more than
6811    /// `batch_size` rows are expired, the cutoff is dropped to the
6812    /// `batch_size`-th oldest expired timestamp + 1 so the predicate
6813    /// matches roughly `batch_size` rows; the remainder is reported
6814    /// as `current_rows_pending_sweep_estimate` and drained on the
6815    /// next tick.
6816    pub fn sweep_retention_tick(&self, batch_size: usize) {
6817        if batch_size == 0 {
6818            return;
6819        }
6820        let now_ms = std::time::SystemTime::now()
6821            .duration_since(std::time::UNIX_EPOCH)
6822            .map(|d| d.as_millis() as u64)
6823            .unwrap_or(0);
6824
6825        let store = self.inner.db.store();
6826        let collections = store.list_collections();
6827        for name in collections {
6828            let Some(contract) = self.inner.db.collection_contract(&name) else {
6829                continue;
6830            };
6831            let Some(retention_ms) = contract.retention_duration_ms else {
6832                continue;
6833            };
6834            let Some(ts_column) =
6835                crate::runtime::retention_filter::resolve_timestamp_column(&contract)
6836            else {
6837                continue;
6838            };
6839            let Some(manager) = store.get_collection(&name) else {
6840                continue;
6841            };
6842            let cutoff = (now_ms as i64).saturating_sub(retention_ms as i64);
6843
6844            // Single pass: collect expired timestamps. We keep the
6845            // full Vec rather than a bounded heap because the partial
6846            // sort below is the simplest correct way to find the
6847            // batch-th oldest; for the slice's "1000-row default
6848            // batch" target this is bounded enough for production
6849            // operation, and the alternative (in-place heap of size
6850            // batch+1) is a follow-up optimisation.
6851            let mut expired_ts: Vec<i64> = Vec::new();
6852            manager.for_each_entity(|entity| {
6853                let ts = match ts_column.as_str() {
6854                    "created_at" => Some(entity.created_at as i64),
6855                    "updated_at" => Some(entity.updated_at as i64),
6856                    other => entity
6857                        .data
6858                        .as_row()
6859                        .and_then(|row| row.get_field(other))
6860                        .and_then(|v| match v {
6861                            crate::storage::schema::Value::TimestampMs(t) => Some(*t),
6862                            crate::storage::schema::Value::Timestamp(t) => {
6863                                Some(t.saturating_mul(1_000))
6864                            }
6865                            crate::storage::schema::Value::BigInt(t) => Some(*t),
6866                            crate::storage::schema::Value::UnsignedInteger(t) => {
6867                                i64::try_from(*t).ok()
6868                            }
6869                            crate::storage::schema::Value::Integer(t) => Some(*t),
6870                            _ => None,
6871                        }),
6872                };
6873                if let Some(t) = ts {
6874                    if t < cutoff {
6875                        expired_ts.push(t);
6876                    }
6877                }
6878                true
6879            });
6880
6881            let total_expired = expired_ts.len() as u64;
6882            if total_expired == 0 {
6883                self.inner
6884                    .retention_sweeper
6885                    .write()
6886                    .record_tick(&name, 0, 0, now_ms);
6887                continue;
6888            }
6889
6890            let (effective_cutoff, pending) = if (total_expired as usize) <= batch_size {
6891                (cutoff, 0u64)
6892            } else {
6893                // Tighten the cutoff to the (batch_size)-th oldest
6894                // expired timestamp + 1 so DELETE matches roughly
6895                // `batch_size` rows.
6896                expired_ts.sort_unstable();
6897                let nth = expired_ts[batch_size - 1];
6898                (
6899                    nth.saturating_add(1),
6900                    total_expired.saturating_sub(batch_size as u64),
6901                )
6902            };
6903
6904            let stmt = format!(
6905                "DELETE FROM {} WHERE {} < {}",
6906                name, ts_column, effective_cutoff
6907            );
6908            let deleted = match self.execute_query(&stmt) {
6909                Ok(r) => r.affected_rows,
6910                Err(_) => 0,
6911            };
6912
6913            self.inner
6914                .retention_sweeper
6915                .write()
6916                .record_tick(&name, deleted, pending, now_ms);
6917        }
6918    }
6919
6920    pub fn refresh_due_materialized_views(&self) {
6921        let due = {
6922            let mut cache = self.inner.materialized_views.write();
6923            cache.claim_due_at(std::time::Instant::now())
6924        };
6925        for name in due {
6926            // Round-trip through `execute_query` (rather than the
6927            // prepared-statement `execute_query_expr` fast path, which
6928            // explicitly rejects DDL/maintenance statements). Failures
6929            // are captured inside the RefreshMaterializedView handler
6930            // via `record_refresh_failure`; the scheduler ignores the
6931            // Result so one bad view doesn't halt the loop.
6932            let stmt = format!("REFRESH MATERIALIZED VIEW {}", name);
6933            let _ = self.execute_query(&stmt);
6934        }
6935    }
6936
6937    /// Execute a pre-parsed `QueryExpr` directly, bypassing SQL parsing and the
6938    /// plan cache. Used by the prepared-statement fast path so that `execute_prepared`
6939    /// calls pay zero parse + cache overhead.
6940    ///
6941    /// Applies secret decryption on SELECT results, identical to `execute_query`.
6942    pub fn execute_query_expr(&self, expr: QueryExpr) -> RedDBResult<RuntimeQueryResult> {
6943        let _config_snapshot_guard = ConfigSnapshotGuard::install(Arc::clone(&self.inner.db));
6944        let _secret_store_guard = SecretStoreGuard::install(self.inner.auth_store.read().clone());
6945        // View rewrite (Phase 2.1): substitute any `QueryExpr::Table(tq)`
6946        // whose `tq.table` matches a registered view with the view's
6947        // underlying query. Safe to call even when no views are registered.
6948        let expr = self.rewrite_view_refs(expr);
6949
6950        self.validate_model_operations_before_auth(&expr)?;
6951        // Granular RBAC privilege check. Runs before dispatch so a
6952        // denied caller never reaches storage. Fail-closed: any error
6953        // resolving the action / resource produces PermissionDenied.
6954        if let Err(err) = self.check_query_privilege(&expr) {
6955            return Err(RedDBError::Query(format!("permission denied: {err}")));
6956        }
6957
6958        let statement = query_expr_name(&expr);
6959        let mode = detect_mode(statement);
6960        let query_str = statement;
6961
6962        let result = self.dispatch_expr(expr, query_str, mode)?;
6963        let mut r = result;
6964        if r.statement_type == "select" {
6965            self.apply_secret_decryption(&mut r);
6966        }
6967        Ok(r)
6968    }
6969
6970    pub(super) fn validate_model_operations_before_auth(
6971        &self,
6972        expr: &QueryExpr,
6973    ) -> RedDBResult<()> {
6974        use crate::catalog::CollectionModel;
6975        use crate::runtime::ddl::polymorphic_resolver;
6976        use crate::storage::query::ast::KvCommand;
6977
6978        let system_schema_target = match expr {
6979            QueryExpr::DropTable(q) => Some(q.name.as_str()),
6980            QueryExpr::DropGraph(q) => Some(q.name.as_str()),
6981            QueryExpr::DropVector(q) => Some(q.name.as_str()),
6982            QueryExpr::DropDocument(q) => Some(q.name.as_str()),
6983            QueryExpr::DropKv(q) => Some(q.name.as_str()),
6984            QueryExpr::DropCollection(q) => Some(q.name.as_str()),
6985            QueryExpr::Truncate(q) => Some(q.name.as_str()),
6986            _ => None,
6987        };
6988        if system_schema_target.is_some_and(crate::runtime::impl_ddl::is_system_schema_name) {
6989            return Err(RedDBError::Query("system schema is read-only".to_string()));
6990        }
6991
6992        let expected = match expr {
6993            QueryExpr::DropTable(q) => Some((q.name.as_str(), CollectionModel::Table)),
6994            QueryExpr::DropGraph(q) => Some((q.name.as_str(), CollectionModel::Graph)),
6995            QueryExpr::DropVector(q) => Some((q.name.as_str(), CollectionModel::Vector)),
6996            QueryExpr::DropDocument(q) => Some((q.name.as_str(), CollectionModel::Document)),
6997            QueryExpr::DropKv(q) => Some((q.name.as_str(), q.model)),
6998            QueryExpr::DropCollection(q) => q.model.map(|model| (q.name.as_str(), model)),
6999            QueryExpr::Truncate(q) => q.model.map(|model| (q.name.as_str(), model)),
7000            QueryExpr::KvCommand(cmd) => {
7001                let (collection, model) = match cmd {
7002                    KvCommand::Put {
7003                        collection, model, ..
7004                    }
7005                    | KvCommand::Get {
7006                        collection, model, ..
7007                    }
7008                    | KvCommand::Incr {
7009                        collection, model, ..
7010                    }
7011                    | KvCommand::Cas {
7012                        collection, model, ..
7013                    }
7014                    | KvCommand::List {
7015                        collection, model, ..
7016                    }
7017                    | KvCommand::Delete {
7018                        collection, model, ..
7019                    } => (collection.as_str(), *model),
7020                    KvCommand::Rotate { collection, .. }
7021                    | KvCommand::History { collection, .. }
7022                    | KvCommand::Purge { collection, .. } => {
7023                        (collection.as_str(), CollectionModel::Vault)
7024                    }
7025                    KvCommand::InvalidateTags { collection, .. } => {
7026                        (collection.as_str(), CollectionModel::Kv)
7027                    }
7028                    KvCommand::Watch {
7029                        collection, model, ..
7030                    } => (collection.as_str(), *model),
7031                    KvCommand::Unseal { collection, .. } => {
7032                        (collection.as_str(), CollectionModel::Vault)
7033                    }
7034                };
7035                Some((collection, model))
7036            }
7037            QueryExpr::ConfigCommand(cmd) => {
7038                self.validate_config_command_before_auth(cmd)?;
7039                None
7040            }
7041            _ => None,
7042        };
7043
7044        let Some((name, expected_model)) = expected else {
7045            return Ok(());
7046        };
7047        let snapshot = self.inner.db.catalog_model_snapshot();
7048        let Some(actual_model) = snapshot
7049            .collections
7050            .iter()
7051            .find(|collection| collection.name == name)
7052            .map(|collection| collection.declared_model.unwrap_or(collection.model))
7053        else {
7054            return Ok(());
7055        };
7056        polymorphic_resolver::ensure_model_match(expected_model, actual_model)
7057    }
7058
7059    /// Walk a `QueryExpr` and replace `QueryExpr::Table(tq)` nodes whose
7060    /// `tq.table` matches a registered view name with the view's stored
7061    /// body. Recurses through joins so `SELECT ... FROM t JOIN myview ...`
7062    /// resolves correctly. Pure operation — no side effects.
7063    pub(super) fn rewrite_view_refs(&self, expr: QueryExpr) -> QueryExpr {
7064        // Fast path: no views registered → return original expression.
7065        if self.inner.views.read().is_empty() {
7066            return expr;
7067        }
7068        self.rewrite_view_refs_inner(expr)
7069    }
7070
7071    fn rewrite_view_refs_inner(&self, expr: QueryExpr) -> QueryExpr {
7072        use crate::storage::query::ast::{Filter, TableSource};
7073        match expr {
7074            QueryExpr::Table(mut tq) => {
7075                // 1. If the TableSource is a subquery, recurse into it so
7076                //    `SELECT ... FROM (SELECT ... FROM myview) t` expands.
7077                //    The legacy `table` field (set to a synthetic
7078                //    "__subq_NNNN" sentinel) stays as-is so callers that
7079                //    read it keep compiling.
7080                if let Some(TableSource::Subquery(body)) = tq.source.take() {
7081                    tq.source = Some(TableSource::Subquery(Box::new(
7082                        self.rewrite_view_refs_inner(*body),
7083                    )));
7084                    return QueryExpr::Table(tq);
7085                }
7086
7087                // 2. Restore the source field (took it above for match).
7088                // When the source was `None` or `TableSource::Name(_)`, the
7089                // real lookup key is `tq.table` — check the view registry.
7090                let maybe_view = {
7091                    let views = self.inner.views.read();
7092                    views.get(&tq.table).cloned()
7093                };
7094                let Some(view) = maybe_view else {
7095                    return QueryExpr::Table(tq);
7096                };
7097
7098                // Issue #594 slice 9b — materialized views are read
7099                // from their backing collection, not by substituting
7100                // the body. Returning the TableQuery as-is lets the
7101                // normal table-read path resolve `SELECT FROM v`
7102                // against the collection provisioned at CREATE time.
7103                if view.materialized {
7104                    return QueryExpr::Table(tq);
7105                }
7106
7107                // Recurse into the view body — views may reference other
7108                // views. The recursion yields the final QueryExpr we need
7109                // to merge the outer's filter / limit / offset into.
7110                let inner_expr = self.rewrite_view_refs_inner((*view.query).clone());
7111
7112                // Phase 5: when the body is a Table we merge the outer
7113                // TableQuery's WHERE / LIMIT / OFFSET into it so stacked
7114                // views filter recursively. Non-table bodies (Search,
7115                // Ask, Vector, Graph, Hybrid) can't meaningfully combine
7116                // with an outer Table query today — return the body
7117                // verbatim; outer predicates are lost. Full projection
7118                // merge lands in Phase 5.2.
7119                match inner_expr {
7120                    QueryExpr::Table(mut inner_tq) => {
7121                        if let Some(outer_filter) = tq.filter.take() {
7122                            inner_tq.filter = Some(match inner_tq.filter.take() {
7123                                Some(existing) => {
7124                                    Filter::And(Box::new(existing), Box::new(outer_filter))
7125                                }
7126                                None => outer_filter,
7127                            });
7128                            // Keep the `Expr` form in lock-step with the
7129                            // merged `Filter`. The executor prefers
7130                            // `where_expr` and nulls `filter` when it is
7131                            // present (see `execute_query_inner`), so a
7132                            // stacked view whose outer predicate was only
7133                            // merged into `filter` would silently drop that
7134                            // predicate at eval time (#635).
7135                            inner_tq.where_expr = inner_tq
7136                                .filter
7137                                .as_ref()
7138                                .map(crate::storage::query::sql_lowering::filter_to_expr);
7139                        }
7140                        if let Some(outer_limit) = tq.limit {
7141                            inner_tq.limit = Some(match inner_tq.limit {
7142                                Some(existing) => existing.min(outer_limit),
7143                                None => outer_limit,
7144                            });
7145                        }
7146                        if let Some(outer_offset) = tq.offset {
7147                            inner_tq.offset = Some(match inner_tq.offset {
7148                                Some(existing) => existing + outer_offset,
7149                                None => outer_offset,
7150                            });
7151                        }
7152                        QueryExpr::Table(inner_tq)
7153                    }
7154                    other => other,
7155                }
7156            }
7157            QueryExpr::Join(mut jq) => {
7158                jq.left = Box::new(self.rewrite_view_refs_inner(*jq.left));
7159                jq.right = Box::new(self.rewrite_view_refs_inner(*jq.right));
7160                QueryExpr::Join(jq)
7161            }
7162            // Other variants don't carry nested QueryExpr that can reference
7163            // a view by table name. Return as-is.
7164            other => other,
7165        }
7166    }
7167
7168    /// Apply table-level read authorization and RLS rewriting for a
7169    /// relational SELECT leaf.
7170    fn authorize_relational_table_select(
7171        &self,
7172        mut table: TableQuery,
7173        frame: &dyn super::statement_frame::ReadFrame,
7174    ) -> RedDBResult<Option<TableQuery>> {
7175        if let Some(TableSource::Subquery(inner)) = table.source.take() {
7176            let authorized_inner = self.authorize_relational_select_expr(*inner, frame)?;
7177            table.source = Some(TableSource::Subquery(Box::new(authorized_inner)));
7178            return Ok(Some(table));
7179        }
7180
7181        self.check_table_column_projection_authz(&table, frame)?;
7182
7183        if self.inner.rls_enabled_tables.read().contains(&table.table) {
7184            return Ok(inject_rls_filters(self, frame, table));
7185        }
7186
7187        Ok(Some(table))
7188    }
7189
7190    fn authorize_relational_join_select(
7191        &self,
7192        mut join: JoinQuery,
7193        frame: &dyn super::statement_frame::ReadFrame,
7194    ) -> RedDBResult<Option<JoinQuery>> {
7195        self.check_join_column_projection_authz(&join, frame)?;
7196        join.left = Box::new(self.authorize_relational_join_child(*join.left, frame)?);
7197        join.right = Box::new(self.authorize_relational_join_child(*join.right, frame)?);
7198        Ok(inject_rls_into_join(self, frame, join))
7199    }
7200
7201    fn authorize_relational_join_child(
7202        &self,
7203        expr: QueryExpr,
7204        frame: &dyn super::statement_frame::ReadFrame,
7205    ) -> RedDBResult<QueryExpr> {
7206        match expr {
7207            QueryExpr::Table(mut table) => {
7208                if let Some(TableSource::Subquery(inner)) = table.source.take() {
7209                    let authorized_inner = self.authorize_relational_select_expr(*inner, frame)?;
7210                    table.source = Some(TableSource::Subquery(Box::new(authorized_inner)));
7211                }
7212                Ok(QueryExpr::Table(table))
7213            }
7214            QueryExpr::Join(join) => self
7215                .authorize_relational_join_select(join, frame)?
7216                .map(QueryExpr::Join)
7217                .ok_or_else(|| {
7218                    RedDBError::Query("permission denied: RLS denied relational subquery".into())
7219                }),
7220            other => Ok(other),
7221        }
7222    }
7223
7224    fn authorize_relational_select_expr(
7225        &self,
7226        expr: QueryExpr,
7227        frame: &dyn super::statement_frame::ReadFrame,
7228    ) -> RedDBResult<QueryExpr> {
7229        match expr {
7230            QueryExpr::Table(table) => self
7231                .authorize_relational_table_select(table, frame)?
7232                .map(QueryExpr::Table)
7233                .ok_or_else(|| {
7234                    RedDBError::Query("permission denied: RLS denied relational subquery".into())
7235                }),
7236            QueryExpr::Join(join) => self
7237                .authorize_relational_join_select(join, frame)?
7238                .map(QueryExpr::Join)
7239                .ok_or_else(|| {
7240                    RedDBError::Query("permission denied: RLS denied relational subquery".into())
7241                }),
7242            other => Ok(other),
7243        }
7244    }
7245
7246    fn check_table_column_projection_authz(
7247        &self,
7248        table: &TableQuery,
7249        frame: &dyn super::statement_frame::ReadFrame,
7250    ) -> RedDBResult<()> {
7251        let Some((username, role)) = frame.identity() else {
7252            return Ok(());
7253        };
7254        let Some(auth_store) = self.inner.auth_store.read().clone() else {
7255            return Ok(());
7256        };
7257
7258        let columns = self.resolved_table_projection_columns(table)?;
7259        let request = ColumnAccessRequest::select(table.table.clone(), columns);
7260        let principal = UserId::from_parts(frame.effective_scope(), username);
7261        let ctx = runtime_iam_context(role, frame.effective_scope());
7262        let outcome = auth_store.check_column_projection_authz(&principal, &request, &ctx);
7263        if outcome.allowed() {
7264            return Ok(());
7265        }
7266
7267        if let Some(denied) = outcome.first_denied_column() {
7268            return Err(RedDBError::Query(format!(
7269                "permission denied: principal=`{username}` cannot select column `{}`",
7270                denied.resource.name
7271            )));
7272        }
7273        Err(RedDBError::Query(format!(
7274            "permission denied: principal=`{username}` cannot select table `{}`",
7275            table.table
7276        )))
7277    }
7278
7279    fn check_join_column_projection_authz(
7280        &self,
7281        join: &JoinQuery,
7282        frame: &dyn super::statement_frame::ReadFrame,
7283    ) -> RedDBResult<()> {
7284        let mut by_table: HashMap<String, BTreeSet<String>> = HashMap::new();
7285        let projections = crate::storage::query::sql_lowering::effective_join_projections(join);
7286        self.collect_join_projection_columns(join, &projections, &mut by_table)?;
7287
7288        for (table, columns) in by_table {
7289            let query = TableQuery {
7290                table,
7291                source: None,
7292                alias: None,
7293                select_items: Vec::new(),
7294                columns: columns.into_iter().map(Projection::Column).collect(),
7295                where_expr: None,
7296                filter: None,
7297                group_by_exprs: Vec::new(),
7298                group_by: Vec::new(),
7299                having_expr: None,
7300                having: None,
7301                order_by: Vec::new(),
7302                limit: None,
7303                limit_param: None,
7304                offset: None,
7305                offset_param: None,
7306                expand: None,
7307                as_of: None,
7308                sessionize: None,
7309                distinct: false,
7310            };
7311            self.check_table_column_projection_authz(&query, frame)?;
7312        }
7313        Ok(())
7314    }
7315
7316    fn collect_join_projection_columns(
7317        &self,
7318        join: &JoinQuery,
7319        projections: &[Projection],
7320        out: &mut HashMap<String, BTreeSet<String>>,
7321    ) -> RedDBResult<()> {
7322        let left = table_side_context(join.left.as_ref());
7323        let right = table_side_context(join.right.as_ref());
7324
7325        if projections
7326            .iter()
7327            .any(|projection| matches!(projection, Projection::All))
7328        {
7329            for side in [left.as_ref(), right.as_ref()].into_iter().flatten() {
7330                out.entry(side.table.clone())
7331                    .or_default()
7332                    .extend(self.table_all_projection_columns(&side.table)?);
7333            }
7334            return Ok(());
7335        }
7336
7337        for projection in projections {
7338            collect_projection_columns_for_join_side(
7339                projection,
7340                left.as_ref(),
7341                right.as_ref(),
7342                out,
7343            )?;
7344        }
7345        Ok(())
7346    }
7347
7348    fn resolved_table_projection_columns(&self, table: &TableQuery) -> RedDBResult<Vec<String>> {
7349        let projections = crate::storage::query::sql_lowering::effective_table_projections(table);
7350        if projections
7351            .iter()
7352            .any(|projection| matches!(projection, Projection::All))
7353        {
7354            return self.table_all_projection_columns(&table.table);
7355        }
7356
7357        let mut columns = BTreeSet::new();
7358        for projection in &projections {
7359            collect_projection_columns_for_table(
7360                projection,
7361                &table.table,
7362                table.alias.as_deref(),
7363                &mut columns,
7364            );
7365        }
7366        Ok(columns.into_iter().collect())
7367    }
7368
7369    fn table_all_projection_columns(&self, table: &str) -> RedDBResult<Vec<String>> {
7370        if let Some(contract) = self.inner.db.collection_contract_arc(table) {
7371            let columns: Vec<String> = contract
7372                .declared_columns
7373                .iter()
7374                .map(|column| column.name.clone())
7375                .collect();
7376            if !columns.is_empty() {
7377                return Ok(columns);
7378            }
7379        }
7380
7381        let records = scan_runtime_table_source_records_limited(&self.inner.db, table, Some(1))?;
7382        Ok(records
7383            .first()
7384            .map(|record| {
7385                record
7386                    .column_names()
7387                    .into_iter()
7388                    .map(|column| column.to_string())
7389                    .collect()
7390            })
7391            .unwrap_or_default())
7392    }
7393
7394    fn resolve_table_expr_subqueries(
7395        &self,
7396        mut table: TableQuery,
7397        frame: &dyn super::statement_frame::ReadFrame,
7398    ) -> RedDBResult<TableQuery> {
7399        // Only a `Subquery` source needs recursive resolution. `.take()`
7400        // would otherwise drop a `Name` / `Function` source on the floor
7401        // (the `if let` skips the body but the take already cleared it),
7402        // which silently broke `SELECT * FROM components(g)` — the TVF
7403        // dispatch downstream keys off `TableSource::Function` and never
7404        // fired. Restore any non-subquery source unchanged (issue #795).
7405        match table.source.take() {
7406            Some(TableSource::Subquery(inner)) => {
7407                let inner = self.resolve_select_expr_subqueries(*inner, frame)?;
7408                table.source = Some(TableSource::Subquery(Box::new(inner)));
7409            }
7410            other => table.source = other,
7411        }
7412
7413        let outer_scopes = relation_scopes_for_query(&QueryExpr::Table(table.clone()));
7414        for item in &mut table.select_items {
7415            if let crate::storage::query::ast::SelectItem::Expr { expr, .. } = item {
7416                *expr = self.resolve_expr_subqueries(expr.clone(), &outer_scopes, frame)?;
7417            }
7418        }
7419        if let Some(where_expr) = table.where_expr.take() {
7420            table.where_expr =
7421                Some(self.resolve_expr_subqueries(where_expr, &outer_scopes, frame)?);
7422            table.filter = None;
7423        }
7424        if let Some(having_expr) = table.having_expr.take() {
7425            table.having_expr =
7426                Some(self.resolve_expr_subqueries(having_expr, &outer_scopes, frame)?);
7427            table.having = None;
7428        }
7429        for expr in &mut table.group_by_exprs {
7430            *expr = self.resolve_expr_subqueries(expr.clone(), &outer_scopes, frame)?;
7431        }
7432        for clause in &mut table.order_by {
7433            if let Some(expr) = clause.expr.take() {
7434                clause.expr = Some(self.resolve_expr_subqueries(expr, &outer_scopes, frame)?);
7435            }
7436        }
7437        Ok(table)
7438    }
7439
7440    fn resolve_select_expr_subqueries(
7441        &self,
7442        expr: QueryExpr,
7443        frame: &dyn super::statement_frame::ReadFrame,
7444    ) -> RedDBResult<QueryExpr> {
7445        match expr {
7446            QueryExpr::Table(table) => self
7447                .resolve_table_expr_subqueries(table, frame)
7448                .map(QueryExpr::Table),
7449            QueryExpr::Join(mut join) => {
7450                join.left = Box::new(self.resolve_select_expr_subqueries(*join.left, frame)?);
7451                join.right = Box::new(self.resolve_select_expr_subqueries(*join.right, frame)?);
7452                Ok(QueryExpr::Join(join))
7453            }
7454            other => Ok(other),
7455        }
7456    }
7457
7458    fn resolve_expr_subqueries(
7459        &self,
7460        expr: crate::storage::query::ast::Expr,
7461        outer_scopes: &[String],
7462        frame: &dyn super::statement_frame::ReadFrame,
7463    ) -> RedDBResult<crate::storage::query::ast::Expr> {
7464        use crate::storage::query::ast::Expr;
7465
7466        match expr {
7467            Expr::Subquery { query, span } => {
7468                let values = self.execute_expr_subquery_values(query, outer_scopes, frame)?;
7469                if values.len() > 1 {
7470                    return Err(RedDBError::Query(
7471                        "scalar subquery returned more than one row".to_string(),
7472                    ));
7473                }
7474                Ok(Expr::Literal {
7475                    value: values.into_iter().next().unwrap_or(Value::Null),
7476                    span,
7477                })
7478            }
7479            Expr::BinaryOp { op, lhs, rhs, span } => Ok(Expr::BinaryOp {
7480                op,
7481                lhs: Box::new(self.resolve_expr_subqueries(*lhs, outer_scopes, frame)?),
7482                rhs: Box::new(self.resolve_expr_subqueries(*rhs, outer_scopes, frame)?),
7483                span,
7484            }),
7485            Expr::UnaryOp { op, operand, span } => Ok(Expr::UnaryOp {
7486                op,
7487                operand: Box::new(self.resolve_expr_subqueries(*operand, outer_scopes, frame)?),
7488                span,
7489            }),
7490            Expr::Cast {
7491                inner,
7492                target,
7493                span,
7494            } => Ok(Expr::Cast {
7495                inner: Box::new(self.resolve_expr_subqueries(*inner, outer_scopes, frame)?),
7496                target,
7497                span,
7498            }),
7499            Expr::FunctionCall { name, args, span } => {
7500                let args = args
7501                    .into_iter()
7502                    .map(|arg| self.resolve_expr_subqueries(arg, outer_scopes, frame))
7503                    .collect::<RedDBResult<Vec<_>>>()?;
7504                Ok(Expr::FunctionCall { name, args, span })
7505            }
7506            Expr::Case {
7507                branches,
7508                else_,
7509                span,
7510            } => {
7511                let branches = branches
7512                    .into_iter()
7513                    .map(|(cond, value)| {
7514                        Ok((
7515                            self.resolve_expr_subqueries(cond, outer_scopes, frame)?,
7516                            self.resolve_expr_subqueries(value, outer_scopes, frame)?,
7517                        ))
7518                    })
7519                    .collect::<RedDBResult<Vec<_>>>()?;
7520                let else_ = else_
7521                    .map(|expr| self.resolve_expr_subqueries(*expr, outer_scopes, frame))
7522                    .transpose()?
7523                    .map(Box::new);
7524                Ok(Expr::Case {
7525                    branches,
7526                    else_,
7527                    span,
7528                })
7529            }
7530            Expr::IsNull {
7531                operand,
7532                negated,
7533                span,
7534            } => Ok(Expr::IsNull {
7535                operand: Box::new(self.resolve_expr_subqueries(*operand, outer_scopes, frame)?),
7536                negated,
7537                span,
7538            }),
7539            Expr::InList {
7540                target,
7541                values,
7542                negated,
7543                span,
7544            } => {
7545                let target =
7546                    Box::new(self.resolve_expr_subqueries(*target, outer_scopes, frame)?);
7547                let mut resolved = Vec::new();
7548                for value in values {
7549                    if let Expr::Subquery { query, .. } = value {
7550                        resolved.extend(
7551                            self.execute_expr_subquery_values(query, outer_scopes, frame)?
7552                                .into_iter()
7553                                .map(Expr::lit),
7554                        );
7555                    } else {
7556                        resolved.push(self.resolve_expr_subqueries(value, outer_scopes, frame)?);
7557                    }
7558                }
7559                Ok(Expr::InList {
7560                    target,
7561                    values: resolved,
7562                    negated,
7563                    span,
7564                })
7565            }
7566            Expr::Between {
7567                target,
7568                low,
7569                high,
7570                negated,
7571                span,
7572            } => Ok(Expr::Between {
7573                target: Box::new(self.resolve_expr_subqueries(*target, outer_scopes, frame)?),
7574                low: Box::new(self.resolve_expr_subqueries(*low, outer_scopes, frame)?),
7575                high: Box::new(self.resolve_expr_subqueries(*high, outer_scopes, frame)?),
7576                negated,
7577                span,
7578            }),
7579            other => Ok(other),
7580        }
7581    }
7582
7583    fn execute_expr_subquery_values(
7584        &self,
7585        subquery: crate::storage::query::ast::ExprSubquery,
7586        outer_scopes: &[String],
7587        frame: &dyn super::statement_frame::ReadFrame,
7588    ) -> RedDBResult<Vec<Value>> {
7589        let query = *subquery.query;
7590        if query_references_outer_scope(&query, outer_scopes) {
7591            return Err(RedDBError::Query(
7592                "NOT_YET_SUPPORTED: correlated subqueries are not supported yet; track follow-up issue #470-correlated-subqueries".to_string(),
7593            ));
7594        }
7595        let query = self.rewrite_view_refs(query);
7596        let query = self.resolve_select_expr_subqueries(query, frame)?;
7597        let query = self.authorize_relational_select_expr(query, frame)?;
7598        let result = match query {
7599            QueryExpr::Table(table) => {
7600                execute_runtime_table_query(&self.inner.db, &table, Some(&self.inner.index_store))?
7601            }
7602            QueryExpr::Join(join) => execute_runtime_join_query(&self.inner.db, &join)?,
7603            other => {
7604                return Err(RedDBError::Query(format!(
7605                    "expression subquery must be a SELECT query, got {}",
7606                    query_expr_name(&other)
7607                )))
7608            }
7609        };
7610        first_column_values(result)
7611    }
7612
7613    fn dispatch_expr(
7614        &self,
7615        expr: QueryExpr,
7616        query_str: &str,
7617        mode: QueryMode,
7618    ) -> RedDBResult<RuntimeQueryResult> {
7619        let statement = query_expr_name(&expr);
7620        match expr {
7621            QueryExpr::Graph(_) | QueryExpr::Path(_) => {
7622                // Graph queries are not cacheable as prepared statements.
7623                Err(RedDBError::Query(
7624                    "graph queries cannot be used as prepared statements".to_string(),
7625                ))
7626            }
7627            QueryExpr::Table(table) => {
7628                let scope = self.ai_scope();
7629                let table = self.resolve_table_expr_subqueries(
7630                    table,
7631                    &scope as &dyn super::statement_frame::ReadFrame,
7632                )?;
7633                // Table-valued functions (e.g. components(g)) dispatch to a
7634                // read-only executor before any catalog/virtual-table routing
7635                // (issue #795).
7636                if let Some(TableSource::Function {
7637                    name,
7638                    args,
7639                    named_args,
7640                }) = table.source.clone()
7641                {
7642                    return Ok(RuntimeQueryResult {
7643                        query: query_str.to_string(),
7644                        mode,
7645                        statement,
7646                        engine: "runtime-graph-tvf",
7647                        result: self.execute_table_function(&name, &args, &named_args)?,
7648                        affected_rows: 0,
7649                        statement_type: "select",
7650                        bookmark: None,
7651                    });
7652                }
7653                // Inline-graph TVF (issue #799) on the prepared-statement /
7654                // direct-expr path. Result caching is wired on the
7655                // `execute_query_inner` path; here we just compute and return.
7656                if let Some(TableSource::InlineGraphFunction {
7657                    name,
7658                    nodes,
7659                    edges,
7660                    named_args,
7661                }) = table.source.clone()
7662                {
7663                    return Ok(RuntimeQueryResult {
7664                        query: query_str.to_string(),
7665                        mode,
7666                        statement,
7667                        engine: "runtime-graph-tvf-inline",
7668                        result: self.execute_inline_graph_function(
7669                            &name,
7670                            &nodes,
7671                            &edges,
7672                            &named_args,
7673                        )?,
7674                        affected_rows: 0,
7675                        statement_type: "select",
7676                        bookmark: None,
7677                    });
7678                }
7679                if super::red_schema::is_virtual_table(&table.table) {
7680                    return Ok(RuntimeQueryResult {
7681                        query: query_str.to_string(),
7682                        mode,
7683                        statement,
7684                        engine: "runtime-red-schema",
7685                        result: super::red_schema::red_query(
7686                            self,
7687                            &table.table,
7688                            &table,
7689                            &scope as &dyn super::statement_frame::ReadFrame,
7690                        )?,
7691                        affected_rows: 0,
7692                        statement_type: "select",
7693                        bookmark: None,
7694                    });
7695                }
7696                // `<graph>.<output>` analytics virtual view (issue #800).
7697                if let Some(view_result) = self.try_resolve_analytics_view(
7698                    &table,
7699                    &scope as &dyn super::statement_frame::ReadFrame,
7700                )? {
7701                    return Ok(RuntimeQueryResult {
7702                        query: query_str.to_string(),
7703                        mode,
7704                        statement,
7705                        engine: "runtime-graph-analytics-view",
7706                        result: view_result,
7707                        affected_rows: 0,
7708                        statement_type: "select",
7709                        bookmark: None,
7710                    });
7711                }
7712                let Some(table_with_rls) = self.authorize_relational_table_select(
7713                    table,
7714                    &scope as &dyn super::statement_frame::ReadFrame,
7715                )?
7716                else {
7717                    return Ok(RuntimeQueryResult {
7718                        query: query_str.to_string(),
7719                        mode,
7720                        statement,
7721                        engine: "runtime-table-rls",
7722                        result: crate::storage::query::unified::UnifiedResult::empty(),
7723                        affected_rows: 0,
7724                        statement_type: "select",
7725                        bookmark: None,
7726                    });
7727                };
7728                Ok(RuntimeQueryResult {
7729                    query: query_str.to_string(),
7730                    mode,
7731                    statement,
7732                    engine: "runtime-table",
7733                    result: execute_runtime_table_query(
7734                        &self.inner.db,
7735                        &table_with_rls,
7736                        Some(&self.inner.index_store),
7737                    )?,
7738                    affected_rows: 0,
7739                    statement_type: "select",
7740                    bookmark: None,
7741                })
7742            }
7743            QueryExpr::Join(join) => {
7744                let scope = self.ai_scope();
7745                let Some(join_with_rls) = self.authorize_relational_join_select(
7746                    join,
7747                    &scope as &dyn super::statement_frame::ReadFrame,
7748                )?
7749                else {
7750                    return Ok(RuntimeQueryResult {
7751                        query: query_str.to_string(),
7752                        mode,
7753                        statement,
7754                        engine: "runtime-join-rls",
7755                        result: crate::storage::query::unified::UnifiedResult::empty(),
7756                        affected_rows: 0,
7757                        statement_type: "select",
7758                        bookmark: None,
7759                    });
7760                };
7761                Ok(RuntimeQueryResult {
7762                    query: query_str.to_string(),
7763                    mode,
7764                    statement,
7765                    engine: "runtime-join",
7766                    result: execute_runtime_join_query(&self.inner.db, &join_with_rls)?,
7767                    affected_rows: 0,
7768                    statement_type: "select",
7769                    bookmark: None,
7770                })
7771            }
7772            QueryExpr::Vector(vector) => Ok(RuntimeQueryResult {
7773                query: query_str.to_string(),
7774                mode,
7775                statement,
7776                engine: "runtime-vector",
7777                result: execute_runtime_vector_query(&self.inner.db, &vector)?,
7778                affected_rows: 0,
7779                statement_type: "select",
7780                bookmark: None,
7781            }),
7782            QueryExpr::Hybrid(hybrid) => Ok(RuntimeQueryResult {
7783                query: query_str.to_string(),
7784                mode,
7785                statement,
7786                engine: "runtime-hybrid",
7787                result: execute_runtime_hybrid_query(&self.inner.db, &hybrid)?,
7788                affected_rows: 0,
7789                statement_type: "select",
7790                bookmark: None,
7791            }),
7792            QueryExpr::Insert(ref insert) if super::red_schema::is_virtual_table(&insert.table) => {
7793                Err(RedDBError::Query(
7794                    super::red_schema::READ_ONLY_ERROR.to_string(),
7795                ))
7796            }
7797            QueryExpr::Update(ref update) if super::red_schema::is_virtual_table(&update.table) => {
7798                Err(RedDBError::Query(
7799                    super::red_schema::READ_ONLY_ERROR.to_string(),
7800                ))
7801            }
7802            QueryExpr::Delete(ref delete) if super::red_schema::is_virtual_table(&delete.table) => {
7803                Err(RedDBError::Query(
7804                    super::red_schema::READ_ONLY_ERROR.to_string(),
7805                ))
7806            }
7807            QueryExpr::Insert(ref insert) => self
7808                .with_deferred_store_wal_for_dml(self.insert_may_emit_events(insert), || {
7809                    self.execute_insert(query_str, insert)
7810                }),
7811            QueryExpr::Update(ref update) => self
7812                .with_deferred_store_wal_for_dml(self.update_may_emit_events(update), || {
7813                    self.execute_update(query_str, update)
7814                }),
7815            QueryExpr::Delete(ref delete) => self
7816                .with_deferred_store_wal_for_dml(self.delete_may_emit_events(delete), || {
7817                    self.execute_delete(query_str, delete)
7818                }),
7819            QueryExpr::SearchCommand(ref cmd) => self.execute_search_command(query_str, cmd),
7820            QueryExpr::Ask(ref ask) => self.execute_ask(query_str, ask),
7821            _ => Err(RedDBError::Query(format!(
7822                "prepared-statement execution does not support {statement} statements"
7823            ))),
7824        }
7825    }
7826
7827    /// Dispatch a graph-collection table-valued function call in FROM
7828    /// position (e.g. `SELECT * FROM components(g)`).
7829    ///
7830    /// Validates the function name and arity here, materializes the whole
7831    /// active graph read-only, then runs the algorithm via the shared
7832    /// `dispatch_graph_algorithm` path. Never mutates the catalog or store.
7833    fn execute_table_function(
7834        &self,
7835        name: &str,
7836        args: &[String],
7837        named_args: &[(String, f64)],
7838    ) -> RedDBResult<crate::storage::query::unified::UnifiedResult> {
7839        if name.eq_ignore_ascii_case("red.diff") {
7840            return self.execute_vcs_diff_tvf(args, named_args);
7841        }
7842        if !is_graph_tvf_name(name) {
7843            return Err(RedDBError::Query(format!("unknown table function: {name}")));
7844        }
7845        // Every graph-collection TVF takes exactly one graph argument.
7846        if args.len() != 1 {
7847            return Err(RedDBError::Query(format!(
7848                "table function '{name}' takes exactly 1 graph argument, got {}",
7849                args.len()
7850            )));
7851        }
7852
7853        // Read-only materialization of the full active graph. Passing `None`
7854        // for the projection uses the full graph store. Like #795/#796, the
7855        // v0 form runs over the whole graph store regardless of the collection
7856        // argument value. Materialization never mutates any store.
7857        let (nodes, edges) = self.materialize_whole_graph_abstract()?;
7858        self.dispatch_graph_algorithm(name, nodes, edges, named_args)
7859    }
7860
7861    fn execute_vcs_diff_tvf(
7862        &self,
7863        args: &[String],
7864        named_args: &[(String, f64)],
7865    ) -> RedDBResult<crate::storage::query::unified::UnifiedResult> {
7866        use crate::application::vcs::{DiffChange, DiffInput};
7867        use crate::storage::query::unified::{UnifiedRecord, UnifiedResult};
7868        use crate::storage::schema::Value;
7869
7870        if !named_args.is_empty() {
7871            return Err(RedDBError::Query(
7872                "red.diff takes only positional commit arguments".to_string(),
7873            ));
7874        }
7875        if args.len() != 2 {
7876            return Err(RedDBError::Query(format!(
7877                "red.diff takes exactly 2 commit arguments, got {}",
7878                args.len()
7879            )));
7880        }
7881
7882        let diff = self.vcs_diff(DiffInput {
7883            from: args[0].clone(),
7884            to: args[1].clone(),
7885            collection: None,
7886            summary_only: false,
7887        })?;
7888        let mut result = UnifiedResult::with_columns(vec![
7889            "from".into(),
7890            "to".into(),
7891            "collection".into(),
7892            "entity_id".into(),
7893            "change".into(),
7894            "before".into(),
7895            "after".into(),
7896        ]);
7897        for entry in diff.entries {
7898            let (change, before, after) = match entry.change {
7899                DiffChange::Added { after } => ("added", Value::Null, json_runtime_value(after)),
7900                DiffChange::Removed { before } => {
7901                    ("removed", json_runtime_value(before), Value::Null)
7902                }
7903                DiffChange::Modified { before, after } => (
7904                    "modified",
7905                    json_runtime_value(before),
7906                    json_runtime_value(after),
7907                ),
7908            };
7909            let mut record = UnifiedRecord::new();
7910            record.set("from", Value::text(diff.from.clone()));
7911            record.set("to", Value::text(diff.to.clone()));
7912            record.set("collection", Value::text(entry.collection));
7913            record.set("entity_id", Value::text(entry.entity_id));
7914            record.set("change", Value::text(change));
7915            record.set("before", before);
7916            record.set("after", after);
7917            result.push(record);
7918        }
7919        Ok(result)
7920    }
7921
7922    /// Dispatch an inline-graph table-valued function call in FROM position
7923    /// (e.g. `SELECT * FROM components(nodes => (…), edges => (…))`, issue
7924    /// #799).
7925    ///
7926    /// Materializes the two subqueries through the normal read path (so RLS,
7927    /// column authz, and MVCC visibility all apply), constructs the abstract
7928    /// graph — the first column of `nodes` is the node id; the first two-or-
7929    /// three columns of `edges` are `(source, target [, weight])` — then runs
7930    /// the same algorithm path used by the graph-collection form. Read-only.
7931    fn execute_inline_graph_function(
7932        &self,
7933        name: &str,
7934        nodes_query: &QueryExpr,
7935        edges_query: &QueryExpr,
7936        named_args: &[(String, f64)],
7937    ) -> RedDBResult<crate::storage::query::unified::UnifiedResult> {
7938        if !is_graph_tvf_name(name) {
7939            return Err(RedDBError::Query(format!("unknown table function: {name}")));
7940        }
7941
7942        let node_result = self.execute_query_expr(nodes_query.clone())?.result;
7943        let nodes = inline_node_ids(name, &node_result)?;
7944
7945        let edge_result = self.execute_query_expr(edges_query.clone())?.result;
7946        let edges = inline_edges(name, &edge_result)?;
7947
7948        self.dispatch_graph_algorithm(name, nodes, edges, named_args)
7949    }
7950
7951    /// Materialize the whole active graph read-only into the abstract
7952    /// `(nodes, edges)` inputs the pure graph algorithms consume.
7953    fn materialize_whole_graph_abstract(
7954        &self,
7955    ) -> RedDBResult<(
7956        Vec<String>,
7957        Vec<(
7958            String,
7959            String,
7960            crate::storage::engine::graph_algorithms::Weight,
7961        )>,
7962    )> {
7963        use crate::storage::engine::graph_algorithms;
7964
7965        let graph = super::graph_dsl::materialize_graph_with_projection(
7966            self.inner.db.store().as_ref(),
7967            None,
7968        )?;
7969        let nodes: Vec<String> = graph.iter_nodes().map(|n| n.id.clone()).collect();
7970        let edges: Vec<(String, String, graph_algorithms::Weight)> = graph
7971            .iter_all_edges()
7972            .into_iter()
7973            .map(|e| (e.source_id, e.target_id, e.weight))
7974            .collect();
7975        Ok((nodes, edges))
7976    }
7977
7978    /// Resolve a `<graph>.<output>` analytics virtual view (issue #800).
7979    ///
7980    /// Returns `Ok(None)` when `table` is not an analytics view — either the
7981    /// name is not dotted, a real collection of that exact name exists (a real
7982    /// collection always wins; no shadowing), the suffix is not a recognised
7983    /// analytics output, or the parent is not a graph. Returns `Ok(Some(_))`
7984    /// with the freshly computed result when it does resolve, and an error when
7985    /// the parent graph exists but the output is not enabled, a declared
7986    /// algorithm is unsupported, or the parent collection's policy denies the
7987    /// read.
7988    ///
7989    /// The view is recomputed on every call (no result-cache write) so it
7990    /// always reflects the current graph data, satisfying the on-demand
7991    /// recompute contract for this slice.
7992    fn try_resolve_analytics_view(
7993        &self,
7994        table: &TableQuery,
7995        frame: &dyn super::statement_frame::ReadFrame,
7996    ) -> RedDBResult<Option<crate::storage::query::unified::UnifiedResult>> {
7997        let full = table.table.as_str();
7998        let Some(dot) = full.rfind('.') else {
7999            return Ok(None);
8000        };
8001        // A real collection literally named `g.communities` always wins.
8002        if self.inner.db.store().get_collection(full).is_some() {
8003            return Ok(None);
8004        }
8005        let graph_name = &full[..dot];
8006        let output_name = &full[dot + 1..];
8007        let Some(output) = crate::catalog::AnalyticsOutput::from_str(output_name) else {
8008            return Ok(None);
8009        };
8010
8011        let contracts = self.inner.db.collection_contracts();
8012        let Some(contract) = contracts.iter().find(|c| c.name == graph_name) else {
8013            return Ok(None);
8014        };
8015        if contract.declared_model != crate::catalog::CollectionModel::Graph {
8016            return Ok(None);
8017        }
8018        let Some(view) = contract
8019            .analytics_config
8020            .iter()
8021            .find(|view| view.output == output)
8022        else {
8023            // The parent graph exists but this output was not declared — a
8024            // clear error beats the misleading "collection not found".
8025            return Err(RedDBError::Query(format!(
8026                "analytics output '{output_name}' is not enabled on graph '{graph_name}'; declare it with WITH ANALYTICS (...)"
8027            )));
8028        };
8029
8030        // Policy inheritance (AC5): route through the parent graph collection's
8031        // read authorization. A policy or RLS rule that denies the parent
8032        // denies its analytics views transitively.
8033        let parent_query = TableQuery::new(graph_name);
8034        if self
8035            .authorize_relational_table_select(parent_query, frame)?
8036            .is_none()
8037        {
8038            return Err(RedDBError::Query(format!(
8039                "permission denied: policy on graph '{graph_name}' denies analytics view '{output_name}'"
8040            )));
8041        }
8042
8043        let (algorithm, named_args) = analytics_view_algorithm(graph_name, view)?;
8044        let (nodes, edges) = self.materialize_whole_graph_abstract()?;
8045        let result = self.dispatch_graph_algorithm(&algorithm, nodes, edges, &named_args)?;
8046        Ok(Some(result))
8047    }
8048
8049    /// Shared algorithm dispatch over abstract `(nodes, edges)` inputs.
8050    ///
8051    /// Both the graph-collection form and the inline-graph form route here so
8052    /// named-argument validation and the projected row shape stay identical
8053    /// across the two signatures (issue #799). Projects each algorithm's
8054    /// native output shape.
8055    fn dispatch_graph_algorithm(
8056        &self,
8057        name: &str,
8058        nodes: Vec<String>,
8059        edges: Vec<(
8060            String,
8061            String,
8062            crate::storage::engine::graph_algorithms::Weight,
8063        )>,
8064        named_args: &[(String, f64)],
8065    ) -> RedDBResult<crate::storage::query::unified::UnifiedResult> {
8066        use crate::storage::engine::graph_algorithms;
8067        use crate::storage::query::unified::UnifiedResult;
8068        use crate::storage::schema::Value;
8069
8070        if name.eq_ignore_ascii_case("components") {
8071            reject_named_args(name, named_args)?;
8072            let assignment = graph_algorithms::connected_components(&nodes, &edges);
8073            let mut result =
8074                UnifiedResult::with_columns(vec!["node_id".into(), "island_id".into()]);
8075            for (node_id, island_id) in assignment {
8076                let mut record = UnifiedRecord::new();
8077                record.set("node_id", Value::text(node_id));
8078                record.set("island_id", Value::Integer(island_id as i64));
8079                result.push(record);
8080            }
8081            return Ok(result);
8082        }
8083
8084        if name.eq_ignore_ascii_case("louvain") {
8085            // The only supported named argument is `resolution` (γ). It
8086            // defaults to 1.0 (classic modularity) and must be a finite,
8087            // strictly positive number — a non-positive (or NaN/inf)
8088            // resolution has no sensible meaning.
8089            let resolution = louvain_resolution(named_args)?;
8090            let assignment = graph_algorithms::louvain(&nodes, &edges, resolution);
8091            let mut result =
8092                UnifiedResult::with_columns(vec!["node_id".into(), "community_id".into()]);
8093            for (node_id, community_id) in assignment {
8094                let mut record = UnifiedRecord::new();
8095                record.set("node_id", Value::text(node_id));
8096                record.set("community_id", Value::Integer(community_id as i64));
8097                result.push(record);
8098            }
8099            return Ok(result);
8100        }
8101
8102        if name.eq_ignore_ascii_case("degree_centrality") {
8103            reject_named_args(name, named_args)?;
8104            let assignment = abstract_degree_centrality(&nodes, &edges);
8105            let mut result = UnifiedResult::with_columns(vec!["node_id".into(), "degree".into()]);
8106            for (node_id, degree) in assignment {
8107                let mut record = UnifiedRecord::new();
8108                record.set("node_id", Value::text(node_id));
8109                record.set("degree", Value::Integer(degree as i64));
8110                result.push(record);
8111            }
8112            return Ok(result);
8113        }
8114
8115        if name.eq_ignore_ascii_case("shortest_path") {
8116            // Scalar named arguments: `src` and `dst` are required node ids,
8117            // `max_hops` is an optional non-negative edge-count cap. Node ids
8118            // in the graph store are integer entity ids rendered as strings, so
8119            // each id arg must be a non-negative whole number; reject anything
8120            // else (fractional, negative, NaN/inf) with a clear message.
8121            let mut src: Option<String> = None;
8122            let mut dst: Option<String> = None;
8123            let mut max_hops: Option<usize> = None;
8124            let as_node_id = |key: &str, value: f64| -> RedDBResult<String> {
8125                if !value.is_finite() || value < 0.0 || value.fract() != 0.0 {
8126                    return Err(RedDBError::Query(format!(
8127                        "table function 'shortest_path' argument '{key}' must be a non-negative integer node id, got {value}"
8128                    )));
8129                }
8130                Ok((value as i64).to_string())
8131            };
8132            for (key, value) in named_args {
8133                if key.eq_ignore_ascii_case("src") {
8134                    src = Some(as_node_id("src", *value)?);
8135                } else if key.eq_ignore_ascii_case("dst") {
8136                    dst = Some(as_node_id("dst", *value)?);
8137                } else if key.eq_ignore_ascii_case("max_hops") {
8138                    if !value.is_finite() || *value < 0.0 || value.fract() != 0.0 {
8139                        return Err(RedDBError::Query(format!(
8140                            "table function 'shortest_path' max_hops must be a non-negative integer, got {value}"
8141                        )));
8142                    }
8143                    max_hops = Some(*value as usize);
8144                } else {
8145                    return Err(RedDBError::Query(format!(
8146                        "table function 'shortest_path' has no named argument '{key}' (expected 'src', 'dst', 'max_hops')"
8147                    )));
8148                }
8149            }
8150            let src = src.ok_or_else(|| {
8151                RedDBError::Query(
8152                    "table function 'shortest_path' requires named argument 'src'".to_string(),
8153                )
8154            })?;
8155            let dst = dst.ok_or_else(|| {
8156                RedDBError::Query(
8157                    "table function 'shortest_path' requires named argument 'dst'".to_string(),
8158                )
8159            })?;
8160
8161            // Columns are always present; an unreachable pair (within the
8162            // optional `max_hops` budget) simply yields zero rows — never an
8163            // error. `hop` is the 0-based index from the source;
8164            // `cumulative_weight` is the running path weight (0 at the source,
8165            // the total at the destination). Edges are treated as undirected,
8166            // consistent with `components` / `louvain`.
8167            let mut result = UnifiedResult::with_columns(vec![
8168                "hop".into(),
8169                "node_id".into(),
8170                "cumulative_weight".into(),
8171            ]);
8172            if let Some(path) =
8173                graph_algorithms::shortest_path(&nodes, &edges, &src, &dst, max_hops)
8174            {
8175                for (hop, (node_id, cumulative_weight)) in path.into_iter().enumerate() {
8176                    let mut record = UnifiedRecord::new();
8177                    record.set("hop", Value::Integer(hop as i64));
8178                    record.set("node_id", Value::text(node_id));
8179                    record.set("cumulative_weight", Value::Float(cumulative_weight));
8180                    result.push(record);
8181                }
8182            }
8183            return Ok(result);
8184        }
8185        // ── Centrality family (issue #797): each returns rows `(node_id,
8186        // score)` over the abstract `(nodes, edges)` graph. Like the other
8187        // graph TVFs the graph is treated as undirected and scores are
8188        // deterministic; the inline-graph form shares this dispatch. ──
8189        if name.eq_ignore_ascii_case("betweenness") {
8190            reject_named_args(name, named_args)?;
8191            return Ok(Self::centrality_result(graph_algorithms::betweenness(
8192                &nodes, &edges,
8193            )));
8194        }
8195        if name.eq_ignore_ascii_case("eigenvector") {
8196            // Optional `max_iterations` (positive integer, default 100) and
8197            // `tolerance` (finite, strictly positive, default 1e-6).
8198            let mut max_iterations = 100_usize;
8199            let mut tolerance = 1e-6_f64;
8200            for (key, value) in named_args {
8201                if key.eq_ignore_ascii_case("max_iterations") {
8202                    max_iterations = parse_positive_iterations("eigenvector", value)?;
8203                } else if key.eq_ignore_ascii_case("tolerance") {
8204                    if !value.is_finite() || *value <= 0.0 {
8205                        return Err(RedDBError::Query(format!(
8206                            "table function 'eigenvector' tolerance must be > 0, got {value}"
8207                        )));
8208                    }
8209                    tolerance = *value;
8210                } else {
8211                    return Err(RedDBError::Query(format!(
8212                        "table function 'eigenvector' has no named argument '{key}' (expected 'max_iterations' or 'tolerance')"
8213                    )));
8214                }
8215            }
8216            return Ok(Self::centrality_result(graph_algorithms::eigenvector(
8217                &nodes,
8218                &edges,
8219                max_iterations,
8220                tolerance,
8221            )));
8222        }
8223        if name.eq_ignore_ascii_case("pagerank") {
8224            // Optional `damping` (in (0, 1), default 0.85) and `max_iterations`
8225            // (positive integer, default 100).
8226            let mut damping = 0.85_f64;
8227            let mut max_iterations = 100_usize;
8228            for (key, value) in named_args {
8229                if key.eq_ignore_ascii_case("damping") {
8230                    if !value.is_finite() || *value <= 0.0 || *value >= 1.0 {
8231                        return Err(RedDBError::Query(format!(
8232                            "table function 'pagerank' damping must be in (0, 1), got {value}"
8233                        )));
8234                    }
8235                    damping = *value;
8236                } else if key.eq_ignore_ascii_case("max_iterations") {
8237                    max_iterations = parse_positive_iterations("pagerank", value)?;
8238                } else {
8239                    return Err(RedDBError::Query(format!(
8240                        "table function 'pagerank' has no named argument '{key}' (expected 'damping' or 'max_iterations')"
8241                    )));
8242                }
8243            }
8244            return Ok(Self::centrality_result(graph_algorithms::pagerank(
8245                &nodes,
8246                &edges,
8247                damping,
8248                max_iterations,
8249            )));
8250        }
8251        Err(RedDBError::Query(format!("unknown table function: {name}")))
8252    }
8253
8254    /// `components(<graph_collection>)` — returns rows `(node_id, island_id)`.
8255    ///
8256    /// Materializes the active graph (nodes + weighted edges) read-only and
8257    /// runs the pure `graph_algorithms::connected_components`. Edges are
8258    /// treated as undirected; island ids are deterministic (ascending order of
8259    /// each component's smallest node).
8260    fn execute_components_tvf(
8261        &self,
8262        _collection: &str,
8263    ) -> RedDBResult<crate::storage::query::unified::UnifiedResult> {
8264        use crate::storage::engine::graph_algorithms;
8265        use crate::storage::query::unified::UnifiedResult;
8266        use crate::storage::schema::Value;
8267
8268        // Read-only materialization of the full active graph. The named
8269        // collection identifies the active graph scope; passing `None` for the
8270        // projection uses the full graph store (the same result
8271        // `active_graph_projection` yields when no projection is registered).
8272        // Materialization never mutates any store.
8273        let graph = super::graph_dsl::materialize_graph_with_projection(
8274            self.inner.db.store().as_ref(),
8275            None,
8276        )?;
8277
8278        // Materialize abstract inputs for the pure algorithm.
8279        let nodes: Vec<String> = graph.iter_nodes().map(|n| n.id.clone()).collect();
8280        let edges: Vec<(String, String, graph_algorithms::Weight)> = graph
8281            .iter_all_edges()
8282            .into_iter()
8283            .map(|e| (e.source_id, e.target_id, e.weight))
8284            .collect();
8285
8286        let assignment = graph_algorithms::connected_components(&nodes, &edges);
8287
8288        // Project into a UnifiedResult with columns ["node_id", "island_id"].
8289        let mut result = UnifiedResult::with_columns(vec!["node_id".into(), "island_id".into()]);
8290        for (node_id, island_id) in assignment {
8291            let mut record = UnifiedRecord::new();
8292            record.set("node_id", Value::text(node_id));
8293            record.set("island_id", Value::Integer(island_id as i64));
8294            result.push(record);
8295        }
8296        Ok(result)
8297    }
8298
8299    /// `louvain(<graph> [, resolution => <f64>])` — returns rows
8300    /// `(node_id, community_id)` (issue #796).
8301    ///
8302    /// Materializes the active graph (nodes + weighted edges) read-only and
8303    /// runs the pure, deterministic `graph_algorithms::louvain`. Edges are
8304    /// treated as undirected; community ids are assigned in ascending order of
8305    /// each community's smallest node, so identical input + resolution always
8306    /// yields identical rows. Like `components`, the v0 form runs over the
8307    /// whole graph store regardless of the collection argument value.
8308    fn execute_louvain_tvf(
8309        &self,
8310        _collection: &str,
8311        resolution: f64,
8312    ) -> RedDBResult<crate::storage::query::unified::UnifiedResult> {
8313        use crate::storage::engine::graph_algorithms;
8314        use crate::storage::query::unified::UnifiedResult;
8315        use crate::storage::schema::Value;
8316
8317        let graph = super::graph_dsl::materialize_graph_with_projection(
8318            self.inner.db.store().as_ref(),
8319            None,
8320        )?;
8321
8322        let nodes: Vec<String> = graph.iter_nodes().map(|n| n.id.clone()).collect();
8323        let edges: Vec<(String, String, graph_algorithms::Weight)> = graph
8324            .iter_all_edges()
8325            .into_iter()
8326            .map(|e| (e.source_id, e.target_id, e.weight))
8327            .collect();
8328
8329        let assignment = graph_algorithms::louvain(&nodes, &edges, resolution);
8330
8331        // Project into a UnifiedResult with columns ["node_id", "community_id"].
8332        let mut result = UnifiedResult::with_columns(vec!["node_id".into(), "community_id".into()]);
8333        for (node_id, community_id) in assignment {
8334            let mut record = UnifiedRecord::new();
8335            record.set("node_id", Value::text(node_id));
8336            record.set("community_id", Value::Integer(community_id as i64));
8337            result.push(record);
8338        }
8339        Ok(result)
8340    }
8341
8342    /// Project `(node_id, score)` centrality rows into a `UnifiedResult` with
8343    /// columns `["node_id", "score"]`; scores are `Value::Float`.
8344    fn centrality_result(
8345        rows: Vec<(String, f64)>,
8346    ) -> crate::storage::query::unified::UnifiedResult {
8347        use crate::storage::query::unified::UnifiedResult;
8348        use crate::storage::schema::Value;
8349        let mut result = UnifiedResult::with_columns(vec!["node_id".into(), "score".into()]);
8350        for (node_id, score) in rows {
8351            let mut record = UnifiedRecord::new();
8352            record.set("node_id", Value::text(node_id));
8353            record.set("score", Value::Float(score));
8354            result.push(record);
8355        }
8356        result
8357    }
8358
8359    /// Ultra-fast path: detect `SELECT * FROM table WHERE _entity_id = N` by string pattern
8360    /// and execute it without SQL parsing or planning. Returns None if pattern doesn't match.
8361    fn try_fast_entity_lookup(&self, query: &str) -> Option<RedDBResult<RuntimeQueryResult>> {
8362        // Pattern: "SELECT * FROM <table> WHERE _entity_id = <id>"
8363        // or "SELECT * FROM <table> WHERE _entity_id =<id>"
8364        let q = query.trim();
8365        if !q.starts_with("SELECT") && !q.starts_with("select") {
8366            return None;
8367        }
8368
8369        // Find "WHERE _entity_id = " or "WHERE _entity_id ="
8370        let where_pos = q
8371            .find("WHERE _entity_id")
8372            .or_else(|| q.find("where _entity_id"))?;
8373        let after_field = &q[where_pos + 16..].trim_start(); // skip "WHERE _entity_id"
8374        let after_eq = after_field.strip_prefix('=')?.trim_start();
8375
8376        // Parse the entity ID number
8377        let id_str = after_eq.trim();
8378        let entity_id: u64 = id_str.parse().ok()?;
8379
8380        // Extract table name: between "FROM " and " WHERE"
8381        let from_pos = q.find("FROM ").or_else(|| q.find("from "))? + 5;
8382        let table = q[from_pos..where_pos].trim();
8383        if table.is_empty()
8384            || table.contains(' ') && !table.contains(" AS ") && !table.contains(" as ")
8385        {
8386            return None; // complex query, fall through
8387        }
8388        let table_name = table.split_whitespace().next()?;
8389
8390        // Direct entity lookup — skips SQL parse, plan cache, result
8391        // cache, view rewriter, RLS gate. Safe because the gating in
8392        // `execute_query` guarantees no scope override / no
8393        // transaction context is active. MVCC visibility is still
8394        // honoured against the current snapshot.
8395        let store = self.inner.db.store();
8396        let entity = store
8397            .get(
8398                table_name,
8399                crate::storage::unified::EntityId::new(entity_id),
8400            )
8401            .filter(entity_visible_under_current_snapshot)
8402            .filter(|entity| {
8403                self.inner
8404                    .db
8405                    .replica_allows_entity_at_read(table_name, entity)
8406            });
8407
8408        let count = if entity.is_some() { 1u64 } else { 0 };
8409
8410        // Materialize a record so downstream consumers that walk
8411        // `result.records` (embedded runtime API, decrypt pass, CLI)
8412        // see the row. Previously only `pre_serialized_json` was
8413        // filled, which caused those consumers to see zero rows and
8414        // skewed benchmarks.
8415        let records: Vec<crate::storage::query::unified::UnifiedRecord> = entity
8416            .as_ref()
8417            .and_then(|e| runtime_table_record_from_entity(e.clone()))
8418            .into_iter()
8419            .collect();
8420
8421        let json = match entity {
8422            Some(ref e) => execute_runtime_serialize_single_entity(e),
8423            None => r#"{"columns":[],"record_count":0,"selection":{"scope":"any"},"records":[]}"#
8424                .to_string(),
8425        };
8426
8427        Some(Ok(RuntimeQueryResult {
8428            query: query.to_string(),
8429            mode: crate::storage::query::modes::QueryMode::Sql,
8430            statement: "select",
8431            engine: "fast-entity-lookup",
8432            result: crate::storage::query::unified::UnifiedResult {
8433                columns: Vec::new(),
8434                records,
8435                stats: crate::storage::query::unified::QueryStats {
8436                    rows_scanned: count,
8437                    ..Default::default()
8438                },
8439                pre_serialized_json: Some(json),
8440            },
8441            affected_rows: 0,
8442            statement_type: "select",
8443            bookmark: None,
8444        }))
8445    }
8446
8447    pub(crate) fn invalidate_plan_cache(&self) {
8448        self.inner.query_cache.write().clear();
8449        self.inner
8450            .ddl_epoch
8451            .fetch_add(1, std::sync::atomic::Ordering::Release);
8452    }
8453
8454    /// Read the monotonic DDL epoch counter. Bumped by every
8455    /// `invalidate_plan_cache` call so prepared-statement holders can
8456    /// detect schema drift between PREPARE and EXECUTE.
8457    pub fn ddl_epoch(&self) -> u64 {
8458        self.inner
8459            .ddl_epoch
8460            .load(std::sync::atomic::Ordering::Acquire)
8461    }
8462
8463    pub(crate) fn clear_table_planner_stats(&self, table: &str) {
8464        let store = self.inner.db.store();
8465        crate::storage::query::planner::stats_catalog::clear_table_stats(store.as_ref(), table);
8466        self.invalidate_plan_cache();
8467    }
8468
8469    /// Replay `tenant_tables.*.column` keys from red_config at boot so
8470    /// `CREATE TABLE ... TENANT BY (col)` declarations persist across
8471    /// restarts (Phase 2.5.4). Reads every row of the `red_config`
8472    /// collection, picks the keys matching the tenant-marker shape,
8473    /// and calls `register_tenant_table` for each.
8474    ///
8475    /// Safe no-op when `red_config` doesn't exist (first boot on a
8476    /// fresh datadir).
8477    pub(crate) fn rehydrate_tenant_tables(&self) {
8478        let store = self.inner.db.store();
8479        let Some(manager) = store.get_collection("red_config") else {
8480            return;
8481        };
8482        // Replay in insertion order (SegmentManager iteration). Multiple
8483        // toggles on the same table leave several rows behind — the
8484        // last one processed wins because each register/unregister
8485        // call overwrites the in-memory state.
8486        for entity in manager.query_all(|_| true) {
8487            let crate::storage::unified::entity::EntityData::Row(row) = &entity.data else {
8488                continue;
8489            };
8490            let Some(named) = &row.named else { continue };
8491            let Some(crate::storage::schema::Value::Text(key)) = named.get("key") else {
8492                continue;
8493            };
8494            // Shape: tenant_tables.{table}.column
8495            let Some(rest) = key.strip_prefix("tenant_tables.") else {
8496                continue;
8497            };
8498            let Some((table, suffix)) = rest.rsplit_once('.') else {
8499                // Issue #205 — a `tenant_tables.*` row that doesn't
8500                // split cleanly is a schema-shape regression: the
8501                // metadata writer must always emit the `.column`
8502                // suffix, so reaching this branch means an upgrade
8503                // with incompatible state or external tampering.
8504                crate::telemetry::operator_event::OperatorEvent::SchemaCorruption {
8505                    collection: "red_config".to_string(),
8506                    detail: format!("malformed tenant_tables key: {key}"),
8507                }
8508                .emit_global();
8509                continue;
8510            };
8511            if suffix != "column" {
8512                crate::telemetry::operator_event::OperatorEvent::SchemaCorruption {
8513                    collection: "red_config".to_string(),
8514                    detail: format!("unexpected tenant_tables suffix: {key}"),
8515                }
8516                .emit_global();
8517                continue;
8518            }
8519            match named.get("value") {
8520                Some(crate::storage::schema::Value::Text(column)) => {
8521                    self.register_tenant_table(table, column);
8522                }
8523                // Null / missing value = DISABLE TENANCY marker.
8524                Some(crate::storage::schema::Value::Null) | None => {
8525                    self.unregister_tenant_table(table);
8526                }
8527                _ => {}
8528            }
8529        }
8530    }
8531
8532    /// Replay every persisted `MaterializedViewDescriptor` from the
8533    /// `red_materialized_view_defs` system collection (issue #593
8534    /// slice 9a). For each descriptor, re-parse the original SQL,
8535    /// extract the `QueryExpr::CreateView` it produced, and populate
8536    /// the in-memory registries (`inner.views` and
8537    /// `inner.materialized_views`) directly — no write paths run, so
8538    /// rehydrate does not re-persist what it just read.
8539    ///
8540    /// Malformed rows (missing `name`/`source_sql`, parse errors) are
8541    /// skipped with a `SchemaCorruption` operator event so a single
8542    /// bad entry does not block startup.
8543    pub(crate) fn rehydrate_materialized_view_descriptors(&self) {
8544        let store = self.inner.db.store();
8545        let descriptors = crate::runtime::continuous_materialized_view::load_all(store.as_ref());
8546        for descriptor in descriptors {
8547            let parsed = match crate::storage::query::parser::parse(&descriptor.source_sql) {
8548                Ok(qc) => qc,
8549                Err(err) => {
8550                    crate::telemetry::operator_event::OperatorEvent::SchemaCorruption {
8551                        collection:
8552                            crate::runtime::continuous_materialized_view::CATALOG_COLLECTION
8553                                .to_string(),
8554                        detail: format!(
8555                            "failed to re-parse materialized-view source for {}: {err}",
8556                            descriptor.name
8557                        ),
8558                    }
8559                    .emit_global();
8560                    continue;
8561                }
8562            };
8563            let crate::storage::query::ast::QueryExpr::CreateView(create) = parsed.query else {
8564                crate::telemetry::operator_event::OperatorEvent::SchemaCorruption {
8565                    collection: crate::runtime::continuous_materialized_view::CATALOG_COLLECTION
8566                        .to_string(),
8567                    detail: format!(
8568                        "materialized-view source for {} did not re-parse as CREATE VIEW",
8569                        descriptor.name
8570                    ),
8571                }
8572                .emit_global();
8573                continue;
8574            };
8575            // Populate in-memory view registry.
8576            let view_name = create.name.clone();
8577            self.inner
8578                .views
8579                .write()
8580                .insert(view_name.clone(), Arc::new(create));
8581            // Materialized cache slot (data empty until next REFRESH).
8582            use crate::storage::cache::result::{MaterializedViewDef, RefreshPolicy};
8583            let refresh = match descriptor.refresh_every_ms {
8584                Some(ms) => RefreshPolicy::Periodic(std::time::Duration::from_millis(ms)),
8585                None => RefreshPolicy::Manual,
8586            };
8587            let def = MaterializedViewDef {
8588                name: view_name.clone(),
8589                query: format!("<parsed view {}>", view_name),
8590                dependencies: descriptor.source_collections.clone(),
8591                refresh,
8592                retention_duration_ms: descriptor.retention_duration_ms,
8593            };
8594            self.inner.materialized_views.write().register(def);
8595            if let Err(err) = self.ensure_materialized_view_backing(&view_name) {
8596                crate::telemetry::operator_event::OperatorEvent::SchemaCorruption {
8597                    collection: crate::runtime::continuous_materialized_view::CATALOG_COLLECTION
8598                        .to_string(),
8599                    detail: format!(
8600                        "failed to rehydrate backing collection for materialized view {view_name}: {err}"
8601                    ),
8602                }
8603                .emit_global();
8604            }
8605        }
8606        // A rehydrated view shape may differ from any plans the cache
8607        // bootstrapped before this method ran — flush to be safe.
8608        self.invalidate_plan_cache();
8609    }
8610
8611    pub(crate) fn rehydrate_declared_column_schemas(&self) {
8612        let store = self.inner.db.store();
8613        for contract in self.inner.db.collection_contracts() {
8614            let columns: Vec<String> = contract
8615                .declared_columns
8616                .iter()
8617                .map(|column| column.name.clone())
8618                .collect();
8619            let Some(manager) = store.get_collection(&contract.name) else {
8620                continue;
8621            };
8622            manager.set_column_schema_if_empty(columns);
8623        }
8624    }
8625
8626    /// Register a table as tenant-scoped (Phase 2.5.4). Installs the
8627    /// in-memory column mapping, the implicit RLS policy, and enables
8628    /// row-level security on the table. Idempotent — re-registering
8629    /// the same `(table, column)` replaces the prior auto-policy.
8630    pub fn register_tenant_table(&self, table: &str, column: &str) {
8631        use crate::storage::query::ast::{
8632            CompareOp, CreatePolicyQuery, Expr, FieldRef, Filter, Span,
8633        };
8634        self.inner
8635            .tenant_tables
8636            .write()
8637            .insert(table.to_string(), column.to_string());
8638
8639        // Build the policy: col = CURRENT_TENANT()
8640        // Uses CompareExpr so the comparison happens at runtime against
8641        // the thread-local tenant value read by the CURRENT_TENANT
8642        // scalar. Spans are synthetic — there's no source location for
8643        // an auto-generated policy.
8644        let lhs = Expr::Column {
8645            field: FieldRef::TableColumn {
8646                table: table.to_string(),
8647                column: column.to_string(),
8648            },
8649            span: Span::synthetic(),
8650        };
8651        let rhs = Expr::FunctionCall {
8652            name: "CURRENT_TENANT".to_string(),
8653            args: Vec::new(),
8654            span: Span::synthetic(),
8655        };
8656        let policy_filter = Filter::CompareExpr {
8657            lhs,
8658            op: CompareOp::Eq,
8659            rhs,
8660        };
8661
8662        let policy = CreatePolicyQuery {
8663            name: "__tenant_iso".to_string(),
8664            table: table.to_string(),
8665            action: None, // None = ALL actions (SELECT/INSERT/UPDATE/DELETE)
8666            role: None,   // None = every role
8667            using: Box::new(policy_filter),
8668            // Auto-tenancy defaults to Table targets. Collections of
8669            // other kinds (graph / vector / queue / timeseries) that
8670            // opt in via `ALTER ... ENABLE TENANCY` should use the
8671            // matching kind — but for now we keep the auto-policy
8672            // kind-agnostic so the evaluator can apply it to any
8673            // entity living in the collection.
8674            target_kind: crate::storage::query::ast::PolicyTargetKind::Table,
8675        };
8676
8677        // Replace any prior auto-policy for this table (column rename).
8678        self.inner.rls_policies.write().insert(
8679            (table.to_string(), "__tenant_iso".to_string()),
8680            Arc::new(policy),
8681        );
8682        self.inner
8683            .rls_enabled_tables
8684            .write()
8685            .insert(table.to_string());
8686
8687        // Auto-build a hash index on the tenant column. Every read/write
8688        // against a tenant-scoped table carries an implicit
8689        // `col = CURRENT_TENANT()` predicate from the auto-policy, so an
8690        // index on that column is on the hot path of every query. Without
8691        // it, every SELECT/UPDATE/DELETE degrades to a full scan.
8692        self.ensure_tenant_index(table, column);
8693    }
8694
8695    /// Auto-create the hash index that backs the tenant-iso RLS predicate.
8696    /// Skipped when:
8697    ///   * the column is dotted (nested path — flat secondary indices
8698    ///     don't cover those today; RLS still works via the policy)
8699    ///   * `__tenant_idx_{table}` already exists (idempotent on rehydrate)
8700    ///   * the user already registered an index whose first column matches
8701    ///     (avoids redundant duplicates of a user-defined composite)
8702    fn ensure_tenant_index(&self, table: &str, column: &str) {
8703        if column.contains('.') {
8704            return;
8705        }
8706        let index_name = format!("__tenant_idx_{table}");
8707        let registry = self.inner.index_store.list_indices(table);
8708        if registry.iter().any(|idx| idx.name == index_name) {
8709            return;
8710        }
8711        if registry
8712            .iter()
8713            .any(|idx| idx.columns.first().map(|c| c.as_str()) == Some(column))
8714        {
8715            return;
8716        }
8717
8718        let store = self.inner.db.store();
8719        let Some(manager) = store.get_collection(table) else {
8720            return;
8721        };
8722        let entities = manager.query_all(|_| true);
8723        let entity_fields: Vec<(
8724            crate::storage::unified::EntityId,
8725            Vec<(String, crate::storage::schema::Value)>,
8726        )> = entities
8727            .iter()
8728            .map(|e| {
8729                let fields = match &e.data {
8730                    crate::storage::EntityData::Row(row) => {
8731                        if let Some(ref named) = row.named {
8732                            named.iter().map(|(k, v)| (k.clone(), v.clone())).collect()
8733                        } else if let Some(ref schema) = row.schema {
8734                            schema
8735                                .iter()
8736                                .zip(row.columns.iter())
8737                                .map(|(k, v)| (k.clone(), v.clone()))
8738                                .collect()
8739                        } else {
8740                            Vec::new()
8741                        }
8742                    }
8743                    crate::storage::EntityData::Node(node) => node
8744                        .properties
8745                        .iter()
8746                        .map(|(k, v)| (k.clone(), v.clone()))
8747                        .collect(),
8748                    _ => Vec::new(),
8749                };
8750                (e.id, fields)
8751            })
8752            .collect();
8753
8754        let columns = vec![column.to_string()];
8755        if self
8756            .inner
8757            .index_store
8758            .create_index(
8759                &index_name,
8760                table,
8761                &columns,
8762                super::index_store::IndexMethodKind::Hash,
8763                false,
8764                &entity_fields,
8765            )
8766            .is_err()
8767        {
8768            return;
8769        }
8770        self.inner
8771            .index_store
8772            .register(super::index_store::RegisteredIndex {
8773                name: index_name,
8774                collection: table.to_string(),
8775                columns,
8776                method: super::index_store::IndexMethodKind::Hash,
8777                unique: false,
8778            });
8779        self.invalidate_plan_cache();
8780    }
8781
8782    /// Drop the auto-generated tenant index, if one exists. Called from
8783    /// `unregister_tenant_table` so DISABLE TENANCY / DROP TABLE clean up.
8784    fn drop_tenant_index(&self, table: &str) {
8785        let index_name = format!("__tenant_idx_{table}");
8786        self.inner.index_store.drop_index(&index_name, table);
8787    }
8788
8789    /// Retrieve the tenant column for a table, if any (Phase 2.5.4).
8790    /// Used by the INSERT auto-fill path to know which column to
8791    /// populate with `current_tenant()` when the user didn't name it.
8792    pub fn tenant_column(&self, table: &str) -> Option<String> {
8793        self.inner.tenant_tables.read().get(table).cloned()
8794    }
8795
8796    /// Remove a table's tenant registration (Phase 2.5.4). Called by
8797    /// DROP TABLE / ALTER TABLE DISABLE TENANCY. Removes the auto-policy
8798    /// but leaves any user-installed explicit policies intact.
8799    pub fn unregister_tenant_table(&self, table: &str) {
8800        self.inner.tenant_tables.write().remove(table);
8801        self.inner
8802            .rls_policies
8803            .write()
8804            .remove(&(table.to_string(), "__tenant_iso".to_string()));
8805        self.drop_tenant_index(table);
8806        // Only clear RLS enablement if no other policies remain.
8807        let has_other_policies = self
8808            .inner
8809            .rls_policies
8810            .read()
8811            .keys()
8812            .any(|(t, _)| t == table);
8813        if !has_other_policies {
8814            self.inner.rls_enabled_tables.write().remove(table);
8815        }
8816    }
8817
8818    /// Record that the running transaction has marked `id` in `collection`
8819    /// for deletion (Phase 2.3.2b MVCC tombstones). `stamper_xid` is the
8820    /// xid that was written into `xmax` — either the parent txn xid or
8821    /// the innermost savepoint sub-xid. Savepoint rollback filters by
8822    /// this xid to revive only its own tombstones.
8823    pub(crate) fn record_pending_tombstone(
8824        &self,
8825        conn_id: u64,
8826        collection: &str,
8827        id: crate::storage::unified::entity::EntityId,
8828        stamper_xid: crate::storage::transaction::snapshot::Xid,
8829        previous_xmax: crate::storage::transaction::snapshot::Xid,
8830    ) {
8831        self.inner
8832            .pending_tombstones
8833            .write()
8834            .entry(conn_id)
8835            .or_default()
8836            .push((collection.to_string(), id, stamper_xid, previous_xmax));
8837    }
8838
8839    pub(crate) fn record_pending_versioned_update(
8840        &self,
8841        conn_id: u64,
8842        collection: &str,
8843        old_id: crate::storage::unified::entity::EntityId,
8844        new_id: crate::storage::unified::entity::EntityId,
8845        stamper_xid: crate::storage::transaction::snapshot::Xid,
8846        previous_xmax: crate::storage::transaction::snapshot::Xid,
8847    ) {
8848        self.inner
8849            .pending_versioned_updates
8850            .write()
8851            .entry(conn_id)
8852            .or_default()
8853            .push((
8854                collection.to_string(),
8855                old_id,
8856                new_id,
8857                stamper_xid,
8858                previous_xmax,
8859            ));
8860    }
8861
8862    fn with_deferred_store_wal_if_transaction<T>(
8863        &self,
8864        f: impl FnOnce() -> RedDBResult<T>,
8865    ) -> RedDBResult<T> {
8866        let conn_id = current_connection_id();
8867        if !self.inner.tx_contexts.read().contains_key(&conn_id) {
8868            return f();
8869        }
8870
8871        crate::storage::UnifiedStore::begin_deferred_store_wal_capture();
8872        let result = f();
8873        let captured = crate::storage::UnifiedStore::take_deferred_store_wal_capture();
8874        match result {
8875            Ok(value) => {
8876                self.record_pending_store_wal_actions(conn_id, captured);
8877                Ok(value)
8878            }
8879            Err(err) => Err(err),
8880        }
8881    }
8882
8883    fn with_deferred_store_wal_for_dml<T>(
8884        &self,
8885        capture_autocommit_events: bool,
8886        f: impl FnOnce() -> RedDBResult<T>,
8887    ) -> RedDBResult<T> {
8888        let conn_id = current_connection_id();
8889        if self.inner.tx_contexts.read().contains_key(&conn_id) {
8890            return self.with_deferred_store_wal_if_transaction(f);
8891        }
8892        if !capture_autocommit_events {
8893            return f();
8894        }
8895
8896        crate::storage::UnifiedStore::begin_deferred_store_wal_capture();
8897        let result = f();
8898        let captured = crate::storage::UnifiedStore::take_deferred_store_wal_capture();
8899        self.inner
8900            .db
8901            .store()
8902            .append_deferred_store_wal_actions(captured)
8903            .map_err(|err| RedDBError::Internal(err.to_string()))?;
8904        result
8905    }
8906
8907    fn insert_may_emit_events(&self, query: &InsertQuery) -> bool {
8908        !query.suppress_events
8909            && self.collection_has_event_subscriptions_for_operation(
8910                &query.table,
8911                crate::catalog::SubscriptionOperation::Insert,
8912            )
8913    }
8914
8915    fn update_may_emit_events(&self, query: &UpdateQuery) -> bool {
8916        !query.suppress_events
8917            && self.collection_has_event_subscriptions_for_operation(
8918                &query.table,
8919                crate::catalog::SubscriptionOperation::Update,
8920            )
8921    }
8922
8923    fn delete_may_emit_events(&self, query: &DeleteQuery) -> bool {
8924        !query.suppress_events
8925            && self.collection_has_event_subscriptions_for_operation(
8926                &query.table,
8927                crate::catalog::SubscriptionOperation::Delete,
8928            )
8929    }
8930
8931    fn collection_has_event_subscriptions_for_operation(
8932        &self,
8933        collection: &str,
8934        operation: crate::catalog::SubscriptionOperation,
8935    ) -> bool {
8936        let Some(contract) = self.db().collection_contract_arc(collection) else {
8937            return false;
8938        };
8939        contract.subscriptions.iter().any(|subscription| {
8940            subscription.enabled
8941                && (subscription.ops_filter.is_empty()
8942                    || subscription.ops_filter.contains(&operation))
8943        })
8944    }
8945
8946    fn record_pending_store_wal_actions(
8947        &self,
8948        conn_id: u64,
8949        actions: crate::storage::unified::DeferredStoreWalActions,
8950    ) {
8951        if actions.is_empty() {
8952            return;
8953        }
8954        let mut guard = self.inner.pending_store_wal_actions.write();
8955        guard.entry(conn_id).or_default().extend(actions);
8956    }
8957
8958    fn flush_pending_store_wal_actions(&self, conn_id: u64) -> RedDBResult<()> {
8959        let Some(actions) = self
8960            .inner
8961            .pending_store_wal_actions
8962            .write()
8963            .remove(&conn_id)
8964        else {
8965            return Ok(());
8966        };
8967        self.inner
8968            .db
8969            .store()
8970            .append_deferred_store_wal_actions(actions)
8971            .map_err(|err| RedDBError::Internal(err.to_string()))
8972    }
8973
8974    fn discard_pending_store_wal_actions(&self, conn_id: u64) {
8975        self.inner
8976            .pending_store_wal_actions
8977            .write()
8978            .remove(&conn_id);
8979    }
8980
8981    fn xid_conflicts_with_snapshot(
8982        &self,
8983        xid: crate::storage::transaction::snapshot::Xid,
8984        snapshot: &crate::storage::transaction::snapshot::Snapshot,
8985        own_xids: &std::collections::HashSet<crate::storage::transaction::snapshot::Xid>,
8986    ) -> bool {
8987        xid != 0
8988            && !own_xids.contains(&xid)
8989            && !self.inner.snapshot_manager.is_aborted(xid)
8990            && !self.inner.snapshot_manager.is_active(xid)
8991            && (xid > snapshot.xid || snapshot.in_progress.contains(&xid))
8992    }
8993
8994    fn conflict_error(
8995        collection: &str,
8996        logical_id: crate::storage::unified::entity::EntityId,
8997        xid: crate::storage::transaction::snapshot::Xid,
8998    ) -> RedDBError {
8999        RedDBError::Query(format!(
9000            "serialization conflict: table row {collection}/{} was modified by concurrent transaction {xid}",
9001            logical_id.raw()
9002        ))
9003    }
9004
9005    fn check_logical_row_conflict(
9006        &self,
9007        collection: &str,
9008        logical_id: crate::storage::unified::entity::EntityId,
9009        excluded_ids: &[crate::storage::unified::entity::EntityId],
9010        snapshot: &crate::storage::transaction::snapshot::Snapshot,
9011        own_xids: &std::collections::HashSet<crate::storage::transaction::snapshot::Xid>,
9012    ) -> RedDBResult<()> {
9013        let store = self.inner.db.store();
9014        let Some(manager) = store.get_collection(collection) else {
9015            return Ok(());
9016        };
9017
9018        for candidate in manager.query_all(|_| true) {
9019            if excluded_ids.contains(&candidate.id) || candidate.logical_id() != logical_id {
9020                continue;
9021            }
9022            if self.xid_conflicts_with_snapshot(candidate.xmin, snapshot, own_xids) {
9023                return Err(Self::conflict_error(collection, logical_id, candidate.xmin));
9024            }
9025            if self.xid_conflicts_with_snapshot(candidate.xmax, snapshot, own_xids) {
9026                return Err(Self::conflict_error(collection, logical_id, candidate.xmax));
9027            }
9028        }
9029        Ok(())
9030    }
9031
9032    pub(crate) fn check_table_row_write_conflicts(
9033        &self,
9034        conn_id: u64,
9035        snapshot: &crate::storage::transaction::snapshot::Snapshot,
9036        own_xids: &std::collections::HashSet<crate::storage::transaction::snapshot::Xid>,
9037    ) -> RedDBResult<()> {
9038        let versioned_updates = self
9039            .inner
9040            .pending_versioned_updates
9041            .read()
9042            .get(&conn_id)
9043            .cloned()
9044            .unwrap_or_default();
9045        let tombstones = self
9046            .inner
9047            .pending_tombstones
9048            .read()
9049            .get(&conn_id)
9050            .cloned()
9051            .unwrap_or_default();
9052
9053        let store = self.inner.db.store();
9054        for (collection, old_id, new_id, xid, previous_xmax) in versioned_updates {
9055            let Some(manager) = store.get_collection(&collection) else {
9056                continue;
9057            };
9058            let Some(old) = manager.get(old_id) else {
9059                continue;
9060            };
9061            let logical_id = old.logical_id();
9062            if self.xid_conflicts_with_snapshot(previous_xmax, snapshot, own_xids) {
9063                return Err(Self::conflict_error(&collection, logical_id, previous_xmax));
9064            }
9065            if old.xmax != xid && self.xid_conflicts_with_snapshot(old.xmax, snapshot, own_xids) {
9066                return Err(Self::conflict_error(&collection, logical_id, old.xmax));
9067            }
9068            self.check_logical_row_conflict(
9069                &collection,
9070                logical_id,
9071                &[old_id, new_id],
9072                snapshot,
9073                own_xids,
9074            )?;
9075        }
9076
9077        for (collection, id, xid, previous_xmax) in tombstones {
9078            let Some(manager) = store.get_collection(&collection) else {
9079                continue;
9080            };
9081            let Some(entity) = manager.get(id) else {
9082                continue;
9083            };
9084            let logical_id = entity.logical_id();
9085            if self.xid_conflicts_with_snapshot(previous_xmax, snapshot, own_xids) {
9086                return Err(Self::conflict_error(&collection, logical_id, previous_xmax));
9087            }
9088            if entity.xmax != xid
9089                && self.xid_conflicts_with_snapshot(entity.xmax, snapshot, own_xids)
9090            {
9091                return Err(Self::conflict_error(&collection, logical_id, entity.xmax));
9092            }
9093            self.check_logical_row_conflict(&collection, logical_id, &[id], snapshot, own_xids)?;
9094        }
9095
9096        Ok(())
9097    }
9098
9099    pub(crate) fn restore_pending_write_stamps(&self, conn_id: u64) {
9100        let versioned_updates = self
9101            .inner
9102            .pending_versioned_updates
9103            .read()
9104            .get(&conn_id)
9105            .cloned()
9106            .unwrap_or_default();
9107        let tombstones = self
9108            .inner
9109            .pending_tombstones
9110            .read()
9111            .get(&conn_id)
9112            .cloned()
9113            .unwrap_or_default();
9114
9115        let store = self.inner.db.store();
9116        for (collection, old_id, _new_id, xid, _previous_xmax) in versioned_updates {
9117            if let Some(manager) = store.get_collection(&collection) {
9118                if let Some(mut entity) = manager.get(old_id) {
9119                    entity.set_xmax(xid);
9120                    let _ = manager.update(entity);
9121                }
9122            }
9123        }
9124        for (collection, id, xid, _previous_xmax) in tombstones {
9125            if let Some(manager) = store.get_collection(&collection) {
9126                if let Some(mut entity) = manager.get(id) {
9127                    entity.set_xmax(xid);
9128                    let _ = manager.update(entity);
9129                }
9130            }
9131        }
9132    }
9133
9134    pub(crate) fn finalize_pending_versioned_updates(&self, conn_id: u64) {
9135        self.inner
9136            .pending_versioned_updates
9137            .write()
9138            .remove(&conn_id);
9139    }
9140
9141    pub(crate) fn revive_pending_versioned_updates(&self, conn_id: u64) {
9142        let Some(pending) = self
9143            .inner
9144            .pending_versioned_updates
9145            .write()
9146            .remove(&conn_id)
9147        else {
9148            return;
9149        };
9150
9151        let store = self.inner.db.store();
9152        for (collection, old_id, new_id, xid, previous_xmax) in pending {
9153            if let Some(manager) = store.get_collection(&collection) {
9154                if let Some(mut old) = manager.get(old_id) {
9155                    if old.xmax == xid {
9156                        old.set_xmax(previous_xmax);
9157                        let _ = manager.update(old);
9158                    }
9159                }
9160            }
9161            let _ = store.delete_batch(&collection, &[new_id]);
9162        }
9163    }
9164
9165    pub(crate) fn revive_versioned_updates_since(&self, conn_id: u64, stamper_xid: u64) -> usize {
9166        let mut guard = self.inner.pending_versioned_updates.write();
9167        let Some(pending) = guard.get_mut(&conn_id) else {
9168            return 0;
9169        };
9170
9171        let store = self.inner.db.store();
9172        let mut reverted = 0usize;
9173        pending.retain(|(collection, old_id, new_id, xid, previous_xmax)| {
9174            if *xid < stamper_xid {
9175                return true;
9176            }
9177            if let Some(manager) = store.get_collection(collection) {
9178                if let Some(mut old) = manager.get(*old_id) {
9179                    if old.xmax == *xid {
9180                        old.set_xmax(*previous_xmax);
9181                        let _ = manager.update(old);
9182                    }
9183                }
9184            }
9185            let _ = store.delete_batch(collection, &[*new_id]);
9186            reverted += 1;
9187            false
9188        });
9189        if pending.is_empty() {
9190            guard.remove(&conn_id);
9191        }
9192        reverted
9193    }
9194
9195    /// Flush tombstones on COMMIT. The xmax stamp is already the durable
9196    /// delete marker; commit only drops the rollback journal and emits
9197    /// side effects. Physical reclamation is left for VACUUM so old
9198    /// snapshots can still resolve the pre-delete row version.
9199    pub(crate) fn finalize_pending_tombstones(&self, conn_id: u64) {
9200        let Some(pending) = self.inner.pending_tombstones.write().remove(&conn_id) else {
9201            return;
9202        };
9203        if pending.is_empty() {
9204            return;
9205        }
9206
9207        let store = self.inner.db.store();
9208        for (collection, id, _xid, _previous_xmax) in pending {
9209            store.context_index().remove_entity(id);
9210            self.cdc_emit(
9211                crate::replication::cdc::ChangeOperation::Delete,
9212                &collection,
9213                id.raw(),
9214                "entity",
9215            );
9216        }
9217    }
9218
9219    /// Revive tombstones on ROLLBACK — reset `xmax` to 0 so the tuples
9220    /// become visible again to future snapshots. Best-effort: a row
9221    /// already reclaimed by a concurrent VACUUM stays gone, but VACUUM
9222    /// never reclaims tuples whose xmax is still referenced by any
9223    /// active snapshot, so this case is only reachable via external
9224    /// storage corruption.
9225    pub(crate) fn revive_pending_tombstones(&self, conn_id: u64) {
9226        let Some(pending) = self.inner.pending_tombstones.write().remove(&conn_id) else {
9227            return;
9228        };
9229
9230        let store = self.inner.db.store();
9231        for (collection, id, xid, previous_xmax) in pending {
9232            let Some(manager) = store.get_collection(&collection) else {
9233                continue;
9234            };
9235            if let Some(mut entity) = manager.get(id) {
9236                if entity.xmax == xid {
9237                    entity.set_xmax(previous_xmax);
9238                    let _ = manager.update(entity);
9239                }
9240            }
9241        }
9242    }
9243
9244    /// Slice C of PRD #718 — accessor for the local wait registry.
9245    pub fn queue_wait_registry(
9246        &self,
9247    ) -> std::sync::Arc<crate::runtime::queue_wait_registry::QueueWaitRegistry> {
9248        self.inner.queue_wait_registry.clone()
9249    }
9250
9251    /// Buffer a `(scope, queue)` wake on the current connection so it
9252    /// fires post-COMMIT, or notify immediately if no transaction is
9253    /// open (autocommit path). The wait registry only ever observes
9254    /// notifies for committed work — rollback drops the buffer.
9255    pub(crate) fn record_queue_wake(&self, scope: &str, queue: &str) {
9256        if self.current_xid().is_some() {
9257            let conn_id = current_connection_id();
9258            self.inner
9259                .pending_queue_wakes
9260                .write()
9261                .entry(conn_id)
9262                .or_default()
9263                .push((scope.to_string(), queue.to_string()));
9264            return;
9265        }
9266        self.inner.queue_wait_registry.notify(scope, queue);
9267    }
9268
9269    pub(crate) fn finalize_pending_queue_wakes(&self, conn_id: u64) {
9270        let Some(pending) = self.inner.pending_queue_wakes.write().remove(&conn_id) else {
9271            return;
9272        };
9273        for (scope, queue) in pending {
9274            self.inner.queue_wait_registry.notify(&scope, &queue);
9275        }
9276    }
9277
9278    pub(crate) fn discard_pending_queue_wakes(&self, conn_id: u64) {
9279        self.inner.pending_queue_wakes.write().remove(&conn_id);
9280    }
9281
9282    pub(crate) fn release_pending_claim_locks(&self, conn_id: u64) {
9283        self.inner
9284            .pending_claim_locks
9285            .write()
9286            .retain(|_, owner| *owner != conn_id);
9287    }
9288
9289    pub(crate) fn finalize_pending_kv_watch_events(&self, conn_id: u64) {
9290        let Some(pending) = self.inner.pending_kv_watch_events.write().remove(&conn_id) else {
9291            return;
9292        };
9293        for event in pending {
9294            self.cdc_emit_kv(
9295                event.op,
9296                &event.collection,
9297                &event.key,
9298                0,
9299                event.before,
9300                event.after,
9301            );
9302        }
9303    }
9304
9305    pub(crate) fn discard_pending_kv_watch_events(&self, conn_id: u64) {
9306        self.inner.pending_kv_watch_events.write().remove(&conn_id);
9307    }
9308
9309    /// Materialise the entire graph store while applying MVCC visibility
9310    /// AND per-collection RLS to each candidate node and edge. Mirrors
9311    /// `materialize_graph` but routes every entity through the same
9312    /// gate the SELECT path uses, with the correct `PolicyTargetKind`
9313    /// per entity kind (`Nodes` for graph nodes, `Edges` for graph
9314    /// edges). Returns the filtered `GraphStore` plus the
9315    /// `node_id → properties` map the executor needs for `RETURN n.*`
9316    /// projections.
9317    fn materialize_graph_with_rls(
9318        &self,
9319    ) -> RedDBResult<(
9320        crate::storage::engine::GraphStore,
9321        std::collections::HashMap<
9322            String,
9323            std::collections::HashMap<String, crate::storage::schema::Value>,
9324        >,
9325        crate::storage::query::unified::EdgeProperties,
9326    )> {
9327        use crate::storage::engine::GraphStore;
9328        use crate::storage::query::ast::{PolicyAction, PolicyTargetKind};
9329        use crate::storage::unified::entity::{EntityData, EntityKind};
9330        use std::collections::{HashMap, HashSet};
9331
9332        let store = self.inner.db.store();
9333        let snap_ctx = capture_current_snapshot();
9334        let role = current_auth_identity().map(|(_, r)| r.as_str().to_string());
9335
9336        let graph = GraphStore::new();
9337        let mut node_properties: HashMap<String, HashMap<String, crate::storage::schema::Value>> =
9338            HashMap::new();
9339        let mut edge_properties: crate::storage::query::unified::EdgeProperties = HashMap::new();
9340        let mut allowed_nodes: HashSet<String> = HashSet::new();
9341
9342        // Per-collection cached compiled filters — Nodes-kind for
9343        // first pass, Edges-kind for the second. None entries mean
9344        // "RLS enabled, zero matching policy → deny all of this kind".
9345        let mut node_rls: HashMap<String, Option<crate::storage::query::ast::Filter>> =
9346            HashMap::new();
9347        let mut edge_rls: HashMap<String, Option<crate::storage::query::ast::Filter>> =
9348            HashMap::new();
9349
9350        let collections = store.list_collections();
9351
9352        // First pass — gather nodes.
9353        for collection in &collections {
9354            let Some(manager) = store.get_collection(collection) else {
9355                continue;
9356            };
9357            let entities = manager.query_all(|_| true);
9358            for entity in entities {
9359                if !entity_visible_with_context(snap_ctx.as_ref(), &entity) {
9360                    continue;
9361                }
9362                let EntityKind::GraphNode(ref node) = entity.kind else {
9363                    continue;
9364                };
9365                if !node_passes_rls(self, collection, role.as_deref(), &mut node_rls, &entity) {
9366                    continue;
9367                }
9368                let id_str = entity.id.raw().to_string();
9369                graph
9370                    .add_node_with_label(
9371                        &id_str,
9372                        &node.label,
9373                        &super::graph_node_label(&node.node_type),
9374                    )
9375                    .map_err(|err| RedDBError::Query(err.to_string()))?;
9376                allowed_nodes.insert(id_str.clone());
9377                if let EntityData::Node(node_data) = &entity.data {
9378                    node_properties.insert(id_str, node_data.properties.clone());
9379                }
9380            }
9381        }
9382
9383        // Second pass — gather edges. An edge appears only when both
9384        // endpoint nodes survived the RLS pass AND the edge itself
9385        // passes its own RLS gate.
9386        for collection in &collections {
9387            let Some(manager) = store.get_collection(collection) else {
9388                continue;
9389            };
9390            let entities = manager.query_all(|_| true);
9391            for entity in entities {
9392                if !entity_visible_with_context(snap_ctx.as_ref(), &entity) {
9393                    continue;
9394                }
9395                let EntityKind::GraphEdge(ref edge) = entity.kind else {
9396                    continue;
9397                };
9398                if !allowed_nodes.contains(&edge.from_node)
9399                    || !allowed_nodes.contains(&edge.to_node)
9400                {
9401                    continue;
9402                }
9403                if !edge_passes_rls(self, collection, role.as_deref(), &mut edge_rls, &entity) {
9404                    continue;
9405                }
9406                let weight = match &entity.data {
9407                    EntityData::Edge(e) => e.weight,
9408                    _ => edge.weight as f32 / 1000.0,
9409                };
9410                let edge_label = super::graph_edge_label(&edge.label);
9411                graph
9412                    .add_edge_with_label(&edge.from_node, &edge.to_node, &edge_label, weight)
9413                    .map_err(|err| RedDBError::Query(err.to_string()))?;
9414                if let EntityData::Edge(edge_data) = &entity.data {
9415                    edge_properties.insert(
9416                        (edge.from_node.clone(), edge_label, edge.to_node.clone()),
9417                        edge_data.properties.clone(),
9418                    );
9419                }
9420            }
9421        }
9422
9423        // Suppress unused-PolicyAction/PolicyTargetKind warnings — both
9424        // are used inside the helper closures via the per-kind helpers
9425        // declared at the bottom of this file.
9426        let _ = (PolicyAction::Select, PolicyTargetKind::Nodes);
9427
9428        Ok((graph, node_properties, edge_properties))
9429    }
9430
9431    /// Phase 1.1 MVCC universal: post-save hook that stamps `xmin` on a
9432    /// freshly-inserted entity when the current connection holds an
9433    /// open transaction. Used by graph / vector / queue / timeseries
9434    /// write paths that go through the DevX builder API (`db.node(...)
9435    /// .save()` and friends) — those live in the storage crate and
9436    /// can't reach `current_xid()` without crossing layers, so the
9437    /// application layer calls this helper right after `save()` to
9438    /// finalise the MVCC stamp.
9439    ///
9440    /// Autocommit (outside BEGIN) is a no-op — no extra lookup or
9441    /// write, so the non-transactional hot path stays untouched.
9442    ///
9443    /// Best-effort: if the collection or entity disappears between
9444    /// the save and the stamp (concurrent DROP), we silently skip.
9445    pub(crate) fn stamp_xmin_if_in_txn(
9446        &self,
9447        collection: &str,
9448        id: crate::storage::unified::entity::EntityId,
9449    ) {
9450        let Some(xid) = self.current_xid() else {
9451            return;
9452        };
9453        let store = self.inner.db.store();
9454        let Some(manager) = store.get_collection(collection) else {
9455            return;
9456        };
9457        if let Some(mut entity) = manager.get(id) {
9458            entity.set_xmin(xid);
9459            let _ = manager.update(entity);
9460        }
9461    }
9462
9463    /// Revive tombstones stamped by `stamper_xid` or any sub-xid
9464    /// allocated after it (Phase 2.3.2e savepoint rollback). Any
9465    /// pending entries with `xid < stamper_xid` stay queued because
9466    /// they belong to the enclosing scope — they'll either flush on
9467    /// COMMIT or revive on an outer ROLLBACK TO SAVEPOINT.
9468    ///
9469    /// Returns the number of tuples whose `xmax` was wiped back to 0.
9470    pub(crate) fn revive_tombstones_since(&self, conn_id: u64, stamper_xid: u64) -> usize {
9471        let mut guard = self.inner.pending_tombstones.write();
9472        let Some(pending) = guard.get_mut(&conn_id) else {
9473            return 0;
9474        };
9475
9476        let store = self.inner.db.store();
9477        let mut revived = 0usize;
9478        pending.retain(|(collection, id, xid, previous_xmax)| {
9479            if *xid < stamper_xid {
9480                // Stamped before the savepoint — keep in queue.
9481                return true;
9482            }
9483            if let Some(manager) = store.get_collection(collection) {
9484                if let Some(mut entity) = manager.get(*id) {
9485                    if entity.xmax == *xid {
9486                        entity.set_xmax(*previous_xmax);
9487                        let _ = manager.update(entity);
9488                        revived += 1;
9489                    }
9490                }
9491            }
9492            false
9493        });
9494        if pending.is_empty() {
9495            guard.remove(&conn_id);
9496        }
9497        revived
9498    }
9499
9500    /// Return the snapshot the current connection should use for visibility
9501    /// checks (Phase 2.3 PG parity).
9502    ///
9503    /// * If the connection is inside a BEGIN-wrapped transaction, reuse
9504    ///   the snapshot stored in its `TxnContext`.
9505    /// * Otherwise (autocommit), capture a fresh snapshot tied to an
9506    ///   implicit xid=0 — the read path treats pre-MVCC rows as always
9507    ///   visible so this degrades to "see everything committed".
9508    pub fn current_snapshot(&self) -> crate::storage::transaction::snapshot::Snapshot {
9509        let conn_id = current_connection_id();
9510        if let Some(ctx) = self.inner.tx_contexts.read().get(&conn_id).cloned() {
9511            return ctx.snapshot;
9512        }
9513        // Autocommit: take a fresh snapshot bounded by `peek_next_xid` so
9514        // every already-committed xid (which is strictly less) passes the
9515        // `xmin <= snap.xid` gate, while concurrently-active xids land in
9516        // the `in_progress` set and stay hidden until they commit. Using
9517        // xid=0 would incorrectly hide every MVCC-stamped tuple.
9518        let high_water = self.inner.snapshot_manager.peek_next_xid();
9519        self.inner.snapshot_manager.snapshot(high_water)
9520    }
9521
9522    /// Xid of the current connection's active transaction, or `None` when
9523    /// running outside a BEGIN/COMMIT block. Write paths call this to
9524    /// decide whether to stamp `xmin`/`xmax` on tuples.
9525    /// Phase 2.3.2e: when a savepoint is open, `writer_xid` returns the
9526    /// sub-xid so new writes can be selectively rolled back. Otherwise
9527    /// the parent txn's xid is returned, matching pre-savepoint
9528    /// behaviour. Callers that need the enclosing *transaction* xid
9529    /// (e.g. VACUUM min-active calculations) should read `ctx.xid`
9530    /// directly.
9531    pub fn current_xid(&self) -> Option<crate::storage::transaction::snapshot::Xid> {
9532        let conn_id = current_connection_id();
9533        self.inner
9534            .tx_contexts
9535            .read()
9536            .get(&conn_id)
9537            .map(|ctx| ctx.writer_xid())
9538    }
9539
9540    /// `true` when the given connection id has an open `BEGIN`. Issue
9541    /// #760 — `OpenStream` consults this to refuse output streams that
9542    /// would otherwise collide with an interactive transaction (see
9543    /// ADR 0029 "Transaction interaction"). HTTP requests pre-dating the
9544    /// connection-id plumbing run with id `0`, which never carries a
9545    /// transaction context, so this returns `false` on those paths.
9546    pub fn connection_in_transaction(&self, conn_id: u64) -> bool {
9547        self.inner.tx_contexts.read().contains_key(&conn_id)
9548    }
9549
9550    /// Access the shared `SnapshotManager` — useful for VACUUM to compute
9551    /// the oldest-active xid when reclaiming dead tuples.
9552    pub fn snapshot_manager(&self) -> Arc<crate::storage::transaction::snapshot::SnapshotManager> {
9553        Arc::clone(&self.inner.snapshot_manager)
9554    }
9555
9556    fn mvcc_vacuum_cutoff_xid(&self) -> crate::storage::transaction::snapshot::Xid {
9557        let manager = &self.inner.snapshot_manager;
9558        let next_xid = manager.peek_next_xid();
9559        let mut cutoff = next_xid;
9560        if let Some(oldest_active) = manager.oldest_active_xid() {
9561            cutoff = cutoff.min(oldest_active);
9562        }
9563        if let Some(oldest_pinned) = manager.oldest_pinned_xid() {
9564            cutoff = cutoff.min(oldest_pinned);
9565        }
9566        let retention_xids = self.config_u64("runtime.mvcc.vacuum_retention_xids", 0);
9567        if retention_xids > 0 {
9568            cutoff = cutoff.min(next_xid.saturating_sub(retention_xids));
9569        }
9570        cutoff
9571    }
9572
9573    fn rebuild_runtime_indexes_for_table(&self, table: &str) -> RedDBResult<()> {
9574        let registered = self.inner.index_store.list_indices(table);
9575        if registered.is_empty() {
9576            return Ok(());
9577        }
9578        let store = self.inner.db.store();
9579        let Some(manager) = store.get_collection(table) else {
9580            return Ok(());
9581        };
9582        let entity_fields = manager
9583            .query_all(|entity| matches!(entity.kind, crate::storage::EntityKind::TableRow { .. }))
9584            .into_iter()
9585            .map(|entity| (entity.id, table_row_index_fields(&entity)))
9586            .collect::<Vec<_>>();
9587
9588        for index in registered {
9589            self.inner.index_store.drop_index(&index.name, table);
9590            self.inner
9591                .index_store
9592                .create_index(
9593                    &index.name,
9594                    table,
9595                    &index.columns,
9596                    index.method,
9597                    index.unique,
9598                    &entity_fields,
9599                )
9600                .map_err(RedDBError::Internal)?;
9601            self.inner.index_store.register(index);
9602        }
9603        self.invalidate_plan_cache();
9604        Ok(())
9605    }
9606
9607    pub(crate) fn persist_runtime_index_descriptor(
9608        &self,
9609        index: super::index_store::RegisteredIndex,
9610    ) -> RedDBResult<()> {
9611        let store = self.inner.db.store();
9612        let _ = store.get_or_create_collection(RUNTIME_INDEX_REGISTRY_COLLECTION);
9613        let entity = crate::storage::UnifiedEntity::new(
9614            crate::storage::EntityId::new(0),
9615            crate::storage::EntityKind::TableRow {
9616                table: std::sync::Arc::from(RUNTIME_INDEX_REGISTRY_COLLECTION),
9617                row_id: 0,
9618            },
9619            crate::storage::EntityData::Row(crate::storage::RowData {
9620                columns: Vec::new(),
9621                named: Some(
9622                    [
9623                        (
9624                            "collection".to_string(),
9625                            crate::storage::schema::Value::text(index.collection.clone()),
9626                        ),
9627                        (
9628                            "name".to_string(),
9629                            crate::storage::schema::Value::text(index.name.clone()),
9630                        ),
9631                        (
9632                            "columns".to_string(),
9633                            crate::storage::schema::Value::text(index.columns.join("\u{1f}")),
9634                        ),
9635                        (
9636                            "method".to_string(),
9637                            crate::storage::schema::Value::text(index_method_kind_as_str(
9638                                index.method,
9639                            )),
9640                        ),
9641                        (
9642                            "resolution".to_string(),
9643                            crate::storage::schema::Value::Integer(i64::from(
9644                                index_method_kind_resolution(index.method),
9645                            )),
9646                        ),
9647                        (
9648                            "unique".to_string(),
9649                            crate::storage::schema::Value::Boolean(index.unique),
9650                        ),
9651                        (
9652                            "dropped".to_string(),
9653                            crate::storage::schema::Value::Boolean(false),
9654                        ),
9655                    ]
9656                    .into_iter()
9657                    .collect(),
9658                ),
9659                schema: None,
9660            }),
9661        );
9662        store
9663            .insert_auto(RUNTIME_INDEX_REGISTRY_COLLECTION, entity)
9664            .map(|_| ())
9665            .map_err(|err| RedDBError::Internal(format!("{err:?}")))
9666    }
9667
9668    pub(crate) fn persist_runtime_index_drop(
9669        &self,
9670        collection: &str,
9671        name: &str,
9672    ) -> RedDBResult<()> {
9673        let store = self.inner.db.store();
9674        let _ = store.get_or_create_collection(RUNTIME_INDEX_REGISTRY_COLLECTION);
9675        let entity = crate::storage::UnifiedEntity::new(
9676            crate::storage::EntityId::new(0),
9677            crate::storage::EntityKind::TableRow {
9678                table: std::sync::Arc::from(RUNTIME_INDEX_REGISTRY_COLLECTION),
9679                row_id: 0,
9680            },
9681            crate::storage::EntityData::Row(crate::storage::RowData {
9682                columns: Vec::new(),
9683                named: Some(
9684                    [
9685                        (
9686                            "collection".to_string(),
9687                            crate::storage::schema::Value::text(collection.to_string()),
9688                        ),
9689                        (
9690                            "name".to_string(),
9691                            crate::storage::schema::Value::text(name.to_string()),
9692                        ),
9693                        (
9694                            "dropped".to_string(),
9695                            crate::storage::schema::Value::Boolean(true),
9696                        ),
9697                    ]
9698                    .into_iter()
9699                    .collect(),
9700                ),
9701                schema: None,
9702            }),
9703        );
9704        store
9705            .insert_auto(RUNTIME_INDEX_REGISTRY_COLLECTION, entity)
9706            .map(|_| ())
9707            .map_err(|err| RedDBError::Internal(format!("{err:?}")))
9708    }
9709
9710    fn rehydrate_runtime_index_registry(&self) -> RedDBResult<()> {
9711        let store = self.inner.db.store();
9712        let Some(manager) = store.get_collection(RUNTIME_INDEX_REGISTRY_COLLECTION) else {
9713            return Ok(());
9714        };
9715        let mut rows = manager.query_all(|_| true);
9716        rows.sort_by_key(|entity| entity.id.raw());
9717
9718        let mut latest = std::collections::HashMap::<
9719            (String, String),
9720            Option<super::index_store::RegisteredIndex>,
9721        >::new();
9722        for entity in rows {
9723            let crate::storage::EntityData::Row(row) = &entity.data else {
9724                continue;
9725            };
9726            let Some(named) = &row.named else {
9727                continue;
9728            };
9729            let Some(collection) = named_text(named, "collection") else {
9730                continue;
9731            };
9732            let Some(name) = named_text(named, "name") else {
9733                continue;
9734            };
9735            let dropped = named_bool(named, "dropped").unwrap_or(false);
9736            let key = (collection.clone(), name.clone());
9737            if dropped {
9738                latest.insert(key, None);
9739                continue;
9740            }
9741            let columns = named_text(named, "columns")
9742                .map(|raw| {
9743                    raw.split('\u{1f}')
9744                        .filter(|part| !part.is_empty())
9745                        .map(str::to_string)
9746                        .collect::<Vec<_>>()
9747                })
9748                .unwrap_or_default();
9749            let resolution = named_i64(named, "resolution")
9750                .and_then(|v| u8::try_from(v).ok())
9751                .unwrap_or(0);
9752            let Some(method) = named_text(named, "method")
9753                .and_then(|raw| index_method_kind_from_str(&raw, resolution))
9754            else {
9755                continue;
9756            };
9757            latest.insert(
9758                key,
9759                Some(super::index_store::RegisteredIndex {
9760                    name,
9761                    collection,
9762                    columns,
9763                    method,
9764                    unique: named_bool(named, "unique").unwrap_or(false),
9765                }),
9766            );
9767        }
9768
9769        for index in latest.into_values().flatten() {
9770            let Some(manager) = store.get_collection(&index.collection) else {
9771                continue;
9772            };
9773            let entity_fields = manager
9774                .query_all(|entity| {
9775                    matches!(entity.kind, crate::storage::EntityKind::TableRow { .. })
9776                })
9777                .into_iter()
9778                .map(|entity| (entity.id, table_row_index_fields(&entity)))
9779                .collect::<Vec<_>>();
9780            self.inner
9781                .index_store
9782                .create_index(
9783                    &index.name,
9784                    &index.collection,
9785                    &index.columns,
9786                    index.method,
9787                    index.unique,
9788                    &entity_fields,
9789                )
9790                .map_err(RedDBError::Internal)?;
9791            self.inner.index_store.register(index);
9792        }
9793        self.invalidate_plan_cache();
9794        Ok(())
9795    }
9796
9797    /// Own-tx xids (parent + open/released savepoints) for the current
9798    /// connection. Transports + tests that build a `SnapshotContext`
9799    /// manually (outside the `execute_query` scope) need this set so
9800    /// the writer's own uncommitted tuples stay visible to self.
9801    pub fn current_txn_own_xids(
9802        &self,
9803    ) -> std::collections::HashSet<crate::storage::transaction::snapshot::Xid> {
9804        let mut set = std::collections::HashSet::new();
9805        if let Some(ctx) = self.inner.tx_contexts.read().get(&current_connection_id()) {
9806            set.insert(ctx.xid);
9807            for (_, sub) in &ctx.savepoints {
9808                set.insert(*sub);
9809            }
9810            for sub in &ctx.released_sub_xids {
9811                set.insert(*sub);
9812            }
9813        }
9814        set
9815    }
9816
9817    /// Access the shared `ForeignTableRegistry` (Phase 3.2 PG parity).
9818    ///
9819    /// Callers use this to check whether a table name is a registered
9820    /// foreign table (`registry.is_foreign_table(name)`) and, if so, to
9821    /// scan it (`registry.scan(name)`). The read-path rewriter consults
9822    /// this before dispatching into native-collection lookup.
9823    pub fn foreign_tables(&self) -> Arc<crate::storage::fdw::ForeignTableRegistry> {
9824        Arc::clone(&self.inner.foreign_tables)
9825    }
9826
9827    /// Is Row-Level Security enabled for this table? (Phase 2.5 PG parity)
9828    pub fn is_rls_enabled(&self, table: &str) -> bool {
9829        self.inner.rls_enabled_tables.read().contains(table)
9830    }
9831
9832    /// Collect the USING predicates that apply to this `(table, role, action)`.
9833    ///
9834    /// Returned filters should be OR-combined (a row passes RLS when *any*
9835    /// matching policy accepts it) and then AND-ed into the query's WHERE.
9836    /// When the table has RLS disabled this returns an empty Vec — callers
9837    /// can fast-path back to the unfiltered read.
9838    pub fn matching_rls_policies(
9839        &self,
9840        table: &str,
9841        role: Option<&str>,
9842        action: crate::storage::query::ast::PolicyAction,
9843    ) -> Vec<crate::storage::query::ast::Filter> {
9844        // Default kind = Table preserves the pre-Phase-2.5.5 behaviour:
9845        // callers that don't name a kind only see Table-scoped
9846        // policies (which is what execute SELECT / UPDATE / DELETE
9847        // expect).
9848        self.matching_rls_policies_for_kind(
9849            table,
9850            role,
9851            action,
9852            crate::storage::query::ast::PolicyTargetKind::Table,
9853        )
9854    }
9855
9856    /// Kind-aware variant used by cross-model scans (Phase 2.5.5).
9857    ///
9858    /// Graph scans request `Nodes` / `Edges`, vector ANN requests
9859    /// `Vectors`, queue consumers request `Messages`, and timeseries
9860    /// range scans request `Points`. Policies tagged with a
9861    /// different kind are skipped so a graph-scoped policy doesn't
9862    /// accidentally gate a table SELECT on the same collection.
9863    pub fn matching_rls_policies_for_kind(
9864        &self,
9865        table: &str,
9866        role: Option<&str>,
9867        action: crate::storage::query::ast::PolicyAction,
9868        kind: crate::storage::query::ast::PolicyTargetKind,
9869    ) -> Vec<crate::storage::query::ast::Filter> {
9870        if !self.is_rls_enabled(table) {
9871            return Vec::new();
9872        }
9873        let policies = self.inner.rls_policies.read();
9874        policies
9875            .iter()
9876            .filter_map(|((t, _), p)| {
9877                if t != table {
9878                    return None;
9879                }
9880                // Kind gate — Table policies also apply to every
9881                // other kind *iff* the policy predicate evaluates
9882                // against entity fields that exist uniformly; the
9883                // caller's kind filter is the stricter check, so
9884                // match literally. Auto-tenancy policies stamp
9885                // Table and the caller passes the concrete kind —
9886                // we allow Table policies to apply cross-kind for
9887                // backwards compat.
9888                if p.target_kind != kind
9889                    && p.target_kind != crate::storage::query::ast::PolicyTargetKind::Table
9890                {
9891                    return None;
9892                }
9893                // Action gate — `None` means "ALL" actions.
9894                if let Some(a) = p.action {
9895                    if a != action {
9896                        return None;
9897                    }
9898                }
9899                // Role gate — `None` means "any role".
9900                if let Some(p_role) = p.role.as_deref() {
9901                    match role {
9902                        Some(r) if r == p_role => {}
9903                        _ => return None,
9904                    }
9905                }
9906                Some((*p.using).clone())
9907            })
9908            .collect()
9909    }
9910
9911    pub(crate) fn refresh_table_planner_stats(&self, table: &str) {
9912        let store = self.inner.db.store();
9913        if let Some(stats) =
9914            crate::storage::query::planner::stats_catalog::analyze_collection(store.as_ref(), table)
9915        {
9916            crate::storage::query::planner::stats_catalog::persist_table_stats(
9917                store.as_ref(),
9918                &stats,
9919            );
9920        } else {
9921            crate::storage::query::planner::stats_catalog::clear_table_stats(store.as_ref(), table);
9922        }
9923        self.invalidate_plan_cache();
9924    }
9925
9926    pub(crate) fn note_table_write(&self, table: &str) {
9927        // Skip the write lock when the table is already marked
9928        // dirty. With single-row UPDATEs in a loop this used to
9929        // grab the planner_dirty_tables write lock N times even
9930        // though the first call already flipped the flag.
9931        let already_dirty = self.inner.planner_dirty_tables.read().contains(table);
9932        if !already_dirty {
9933            self.inner
9934                .planner_dirty_tables
9935                .write()
9936                .insert(table.to_string());
9937        }
9938        self.invalidate_result_cache_for_table(table);
9939    }
9940
9941    /// Wrap the planner's `RuntimeQueryExplain` as rows on a
9942    /// `RuntimeQueryResult` so callers over the SQL interface see the
9943    /// plan tree in the same shape a SELECT produces.
9944    ///
9945    /// Columns: `op`, `source`, `est_rows`, `est_cost`, `depth`.
9946    /// Nodes are walked depth-first; `depth` counts from 0 at the
9947    /// root so a text renderer can indent without re-walking.
9948    fn explain_as_rows(&self, raw_query: &str, inner_sql: &str) -> RedDBResult<RuntimeQueryResult> {
9949        let explain = self.explain_query(inner_sql)?;
9950
9951        let columns = vec![
9952            "op".to_string(),
9953            "source".to_string(),
9954            "est_rows".to_string(),
9955            "est_cost".to_string(),
9956            "depth".to_string(),
9957        ];
9958
9959        let mut records: Vec<crate::storage::query::unified::UnifiedRecord> = Vec::new();
9960
9961        // Prepend `CteScan` markers when the query carried a leading
9962        // WITH clause. The CTE bodies are already inlined into the
9963        // main plan tree, but operators reading EXPLAIN need to see
9964        // which named CTEs were resolved — without this row the plan
9965        // would look indistinguishable from a hand-inlined query.
9966        for name in &explain.cte_materializations {
9967            use std::sync::Arc;
9968            let mut rec = crate::storage::query::unified::UnifiedRecord::default();
9969            rec.set_arc(Arc::from("op"), Value::text("CteScan".to_string()));
9970            rec.set_arc(Arc::from("source"), Value::text(name.clone()));
9971            rec.set_arc(Arc::from("est_rows"), Value::Float(0.0));
9972            rec.set_arc(Arc::from("est_cost"), Value::Float(0.0));
9973            rec.set_arc(Arc::from("depth"), Value::Integer(0));
9974            records.push(rec);
9975        }
9976
9977        walk_plan_node(&explain.logical_plan.root, 0, &mut records);
9978
9979        let result = crate::storage::query::unified::UnifiedResult {
9980            columns,
9981            records,
9982            stats: Default::default(),
9983            pre_serialized_json: None,
9984        };
9985
9986        Ok(RuntimeQueryResult {
9987            query: raw_query.to_string(),
9988            mode: explain.mode,
9989            statement: "explain",
9990            engine: "runtime-explain",
9991            result,
9992            affected_rows: 0,
9993            statement_type: "select",
9994            bookmark: None,
9995        })
9996    }
9997
9998    // -----------------------------------------------------------------
9999    // Granular RBAC — privilege gate + GRANT/REVOKE/ALTER USER dispatch
10000    // -----------------------------------------------------------------
10001
10002    /// Project a `QueryExpr` to the (action, resource) pair the
10003    /// privilege engine cares about. Returns `Ok(())` for statements
10004    /// that don't touch user data (transaction control, SHOW, SET, etc.).
10005    pub(crate) fn check_query_privilege(
10006        &self,
10007        expr: &crate::storage::query::ast::QueryExpr,
10008    ) -> Result<(), String> {
10009        use crate::auth::privileges::{Action, AuthzContext, Resource};
10010        use crate::auth::UserId;
10011        use crate::storage::query::ast::QueryExpr;
10012
10013        // No auth store wired (embedded mode / fresh DB / tests) → bypass.
10014        // The bootstrap path itself goes through `execute_query` so this
10015        // is the only sensible default; once auth is wired, the gate
10016        // becomes active.
10017        let auth_store = match self.inner.auth_store.read().clone() {
10018            Some(s) => s,
10019            None => return Ok(()),
10020        };
10021
10022        // Resolve principal + role from the thread-local identity.
10023        // Anonymous (no identity) is allowed to read the bootstrap path
10024        // only when auth_store says so; we treat missing identity as
10025        // platform-admin-equivalent here so embedded test harnesses
10026        // continue to work without setting an identity.
10027        let (username, role) = match current_auth_identity() {
10028            Some(p) => p,
10029            None => return Ok(()),
10030        };
10031        let tenant = current_tenant();
10032
10033        let ctx = AuthzContext {
10034            principal: &username,
10035            effective_role: role,
10036            tenant: tenant.as_deref(),
10037        };
10038        let principal_id = UserId::from_parts(tenant.as_deref(), &username);
10039
10040        // Map QueryExpr → (Action, Resource).
10041        let (action, resource) = match expr {
10042            QueryExpr::Table(t) => (Action::Select, Resource::table_from_name(&t.table)),
10043            QueryExpr::RankOf(_) | QueryExpr::ApproxRankOf(_) | QueryExpr::RankRange(_) => {
10044                (Action::Select, Resource::Database)
10045            }
10046            QueryExpr::QueueSelect(q) => {
10047                return self.check_queue_op_privilege(
10048                    &auth_store,
10049                    &principal_id,
10050                    role,
10051                    tenant.as_deref(),
10052                    "queue:peek",
10053                    &q.queue,
10054                );
10055            }
10056            QueryExpr::QueueCommand(cmd) => {
10057                use crate::storage::query::ast::QueueCommand;
10058                let (queue, action_verb) = match cmd {
10059                    QueueCommand::Push { queue, .. } => (queue.as_str(), "queue:enqueue"),
10060                    QueueCommand::Pop { queue, .. }
10061                    | QueueCommand::GroupRead { queue, .. }
10062                    | QueueCommand::Claim { queue, .. } => (queue.as_str(), "queue:read"),
10063                    QueueCommand::Peek { queue, .. }
10064                    | QueueCommand::Len { queue }
10065                    | QueueCommand::Pending { queue, .. } => (queue.as_str(), "queue:peek"),
10066                    QueueCommand::Ack { queue, .. } => (queue.as_str(), "queue:ack"),
10067                    QueueCommand::Nack {
10068                        queue, delay_ms, ..
10069                    } => {
10070                        // Per-failure retry overrides re-shape retry
10071                        // behaviour for everyone draining the queue and
10072                        // gate on the dedicated `queue:retry` verb so
10073                        // operators can grant base NACK without granting
10074                        // the override capability.
10075                        let verb = if delay_ms.is_some() {
10076                            "queue:retry"
10077                        } else {
10078                            "queue:nack"
10079                        };
10080                        (queue.as_str(), verb)
10081                    }
10082                    QueueCommand::Purge { queue } => (queue.as_str(), "queue:purge"),
10083                    // `GroupCreate` is part of the consumer-setup
10084                    // surface — read-side, never destructive.
10085                    QueueCommand::GroupCreate { queue, .. } => (queue.as_str(), "queue:read"),
10086                    QueueCommand::Move { source, .. } => (source.as_str(), "queue:dlq:move"),
10087                };
10088                return self.check_queue_op_privilege(
10089                    &auth_store,
10090                    &principal_id,
10091                    role,
10092                    tenant.as_deref(),
10093                    action_verb,
10094                    queue,
10095                );
10096            }
10097            QueryExpr::Graph(g) => {
10098                // MATCH … RETURN is the explorer's pattern-traversal
10099                // surface — gate on `graph:traverse` (#757).
10100                self.check_graph_op_privilege(
10101                    &auth_store,
10102                    &principal_id,
10103                    role,
10104                    tenant.as_deref(),
10105                    "graph:traverse",
10106                )?;
10107                if auth_store.iam_authorization_enabled() {
10108                    self.check_graph_property_projection_privilege(
10109                        &auth_store,
10110                        &principal_id,
10111                        role,
10112                        tenant.as_deref(),
10113                        g,
10114                    )?;
10115                    return Ok(());
10116                }
10117                return Ok(());
10118            }
10119            QueryExpr::Path(_) => {
10120                // PATH FROM … TO … is a path-traversal query — gates
10121                // on `graph:traverse` like neighborhood/shortest-path
10122                // (#757).
10123                return self.check_graph_op_privilege(
10124                    &auth_store,
10125                    &principal_id,
10126                    role,
10127                    tenant.as_deref(),
10128                    "graph:traverse",
10129                );
10130            }
10131            QueryExpr::GraphCommand(cmd) => {
10132                use crate::storage::query::ast::GraphCommand;
10133                let action_verb = match cmd {
10134                    // Metadata / property reads.
10135                    GraphCommand::Properties { .. } => "graph:read",
10136                    // Traversal / pattern-walk surface.
10137                    GraphCommand::Neighborhood { .. }
10138                    | GraphCommand::Traverse { .. }
10139                    | GraphCommand::ShortestPath { .. } => "graph:traverse",
10140                    // Analytics algorithms — expensive enough that Red
10141                    // UI needs to gate the runner independently of
10142                    // ordinary traversal.
10143                    GraphCommand::Centrality { .. }
10144                    | GraphCommand::Community { .. }
10145                    | GraphCommand::Components { .. }
10146                    | GraphCommand::Cycles { .. }
10147                    | GraphCommand::Clustering
10148                    | GraphCommand::TopologicalSort => "graph:algorithm:run",
10149                };
10150                return self.check_graph_op_privilege(
10151                    &auth_store,
10152                    &principal_id,
10153                    role,
10154                    tenant.as_deref(),
10155                    action_verb,
10156                );
10157            }
10158            QueryExpr::Vector(v) => {
10159                if auth_store.iam_authorization_enabled() {
10160                    self.check_vector_op_privilege(
10161                        &auth_store,
10162                        &principal_id,
10163                        role,
10164                        tenant.as_deref(),
10165                        "vector:search",
10166                        &v.collection,
10167                    )?;
10168                    self.check_table_like_column_projection_privilege(
10169                        &auth_store,
10170                        &principal_id,
10171                        role,
10172                        tenant.as_deref(),
10173                        &v.collection,
10174                        &["content".to_string()],
10175                    )?;
10176                    return Ok(());
10177                }
10178                return Ok(());
10179            }
10180            QueryExpr::SearchCommand(cmd) => {
10181                use crate::storage::query::ast::SearchCommand;
10182                if auth_store.iam_authorization_enabled() {
10183                    // `SEARCH SIMILAR [..] COLLECTION <c>` and `SEARCH
10184                    // HYBRID ... COLLECTION <c>` are the same UI
10185                    // affordances as `VECTOR SEARCH` / hybrid joins —
10186                    // Red UI must see the same `vector:search` envelope
10187                    // so a single toolbar grant is sufficient.
10188                    let collection = match cmd {
10189                        SearchCommand::Similar { collection, .. }
10190                        | SearchCommand::Hybrid { collection, .. } => Some(collection.as_str()),
10191                        _ => None,
10192                    };
10193                    if let Some(c) = collection {
10194                        self.check_vector_op_privilege(
10195                            &auth_store,
10196                            &principal_id,
10197                            role,
10198                            tenant.as_deref(),
10199                            "vector:search",
10200                            c,
10201                        )?;
10202                        return Ok(());
10203                    }
10204                }
10205                return Ok(());
10206            }
10207            QueryExpr::Hybrid(h) => {
10208                if auth_store.iam_authorization_enabled() {
10209                    // The vector half of a hybrid search is gated under
10210                    // the same `vector:search` verb as a standalone
10211                    // VECTOR SEARCH — Red UI's hybrid-search toolbar
10212                    // must surface the same UI-safe denial envelope
10213                    // when the principal lacks the grant. The
10214                    // structured half is dispatched to its own gate via
10215                    // the inner query during execution.
10216                    self.check_vector_op_privilege(
10217                        &auth_store,
10218                        &principal_id,
10219                        role,
10220                        tenant.as_deref(),
10221                        "vector:search",
10222                        &h.vector.collection,
10223                    )?;
10224                    return Ok(());
10225                }
10226                return Ok(());
10227            }
10228            QueryExpr::Insert(i) => (Action::Insert, Resource::table_from_name(&i.table)),
10229            QueryExpr::Update(u) => (Action::Update, Resource::table_from_name(&u.table)),
10230            QueryExpr::Delete(d) => (Action::Delete, Resource::table_from_name(&d.table)),
10231            // Joins inherit the read privilege from any constituent
10232            // table — for now we emit a single Select on the database
10233            // (admins bypass; non-admins need a Database/Schema grant).
10234            QueryExpr::Join(_) => (Action::Select, Resource::Database),
10235            // GRANT / REVOKE / USER DDL are authority statements;
10236            // require Admin (the helper methods enforce).
10237            QueryExpr::Grant(_)
10238            | QueryExpr::Revoke(_)
10239            | QueryExpr::AlterUser(_)
10240            | QueryExpr::CreateUser(_) => {
10241                return if role == crate::auth::Role::Admin {
10242                    Ok(())
10243                } else {
10244                    Err(format!(
10245                        "principal=`{}` role=`{:?}` cannot issue ACL/auth DDL",
10246                        username, role
10247                    ))
10248                };
10249            }
10250            QueryExpr::CreateIamPolicy { id, .. } => {
10251                return self.check_policy_management_privilege(
10252                    &auth_store,
10253                    &principal_id,
10254                    role,
10255                    tenant.as_deref(),
10256                    "policy:put",
10257                    "policy",
10258                    id,
10259                );
10260            }
10261            QueryExpr::DropIamPolicy { id } => {
10262                return self.check_policy_management_privilege(
10263                    &auth_store,
10264                    &principal_id,
10265                    role,
10266                    tenant.as_deref(),
10267                    "policy:drop",
10268                    "policy",
10269                    id,
10270                );
10271            }
10272            QueryExpr::AttachPolicy { policy_id, .. } => {
10273                return self.check_policy_management_privilege(
10274                    &auth_store,
10275                    &principal_id,
10276                    role,
10277                    tenant.as_deref(),
10278                    "policy:attach",
10279                    "policy",
10280                    policy_id,
10281                );
10282            }
10283            QueryExpr::DetachPolicy { policy_id, .. } => {
10284                return self.check_policy_management_privilege(
10285                    &auth_store,
10286                    &principal_id,
10287                    role,
10288                    tenant.as_deref(),
10289                    "policy:detach",
10290                    "policy",
10291                    policy_id,
10292                );
10293            }
10294            QueryExpr::ShowPolicies { .. } | QueryExpr::ShowEffectivePermissions { .. } => {
10295                return Ok(());
10296            }
10297            QueryExpr::SimulatePolicy { .. } => {
10298                return self.check_policy_management_privilege(
10299                    &auth_store,
10300                    &principal_id,
10301                    role,
10302                    tenant.as_deref(),
10303                    "policy:simulate",
10304                    "policy",
10305                    "*",
10306                );
10307            }
10308            QueryExpr::LintPolicy { .. } => {
10309                // Linting is a read-only inspection — gate it like
10310                // simulate (policy management role).
10311                return self.check_policy_management_privilege(
10312                    &auth_store,
10313                    &principal_id,
10314                    role,
10315                    tenant.as_deref(),
10316                    "policy:simulate",
10317                    "policy",
10318                    "*",
10319                );
10320            }
10321            QueryExpr::MigratePolicyMode { dry_run, .. } => {
10322                // DRY RUN is a pre-flight inspection (policy:simulate).
10323                // The actual mode flip is a privileged mutation under
10324                // the policy:put action (it persists a new enforcement
10325                // mode to the vault KV through `set_enforcement_mode`).
10326                let action = if *dry_run {
10327                    "policy:simulate"
10328                } else {
10329                    "policy:put"
10330                };
10331                return self.check_policy_management_privilege(
10332                    &auth_store,
10333                    &principal_id,
10334                    role,
10335                    tenant.as_deref(),
10336                    action,
10337                    "policy",
10338                    "*",
10339                );
10340            }
10341            // DROP and TRUNCATE — Write-role gate + per-collection IAM policy
10342            // when IAM mode is active. Other DDL stays role-only for now.
10343            QueryExpr::DropTable(q) => {
10344                return self.check_ddl_collection_privilege(
10345                    &auth_store,
10346                    &principal_id,
10347                    role,
10348                    tenant.as_deref(),
10349                    &username,
10350                    "drop",
10351                    &q.name,
10352                );
10353            }
10354            QueryExpr::DropGraph(q) => {
10355                return self.check_ddl_collection_privilege(
10356                    &auth_store,
10357                    &principal_id,
10358                    role,
10359                    tenant.as_deref(),
10360                    &username,
10361                    "drop",
10362                    &q.name,
10363                );
10364            }
10365            QueryExpr::DropVector(q) => {
10366                return self.check_ddl_collection_privilege(
10367                    &auth_store,
10368                    &principal_id,
10369                    role,
10370                    tenant.as_deref(),
10371                    &username,
10372                    "drop",
10373                    &q.name,
10374                );
10375            }
10376            QueryExpr::DropDocument(q) => {
10377                return self.check_ddl_collection_privilege(
10378                    &auth_store,
10379                    &principal_id,
10380                    role,
10381                    tenant.as_deref(),
10382                    &username,
10383                    "drop",
10384                    &q.name,
10385                );
10386            }
10387            QueryExpr::DropKv(q) => {
10388                return self.check_ddl_collection_privilege(
10389                    &auth_store,
10390                    &principal_id,
10391                    role,
10392                    tenant.as_deref(),
10393                    &username,
10394                    "drop",
10395                    &q.name,
10396                );
10397            }
10398            QueryExpr::DropCollection(q) => {
10399                return self.check_ddl_collection_privilege(
10400                    &auth_store,
10401                    &principal_id,
10402                    role,
10403                    tenant.as_deref(),
10404                    &username,
10405                    "drop",
10406                    &q.name,
10407                );
10408            }
10409            QueryExpr::Truncate(q) => {
10410                return self.check_ddl_collection_privilege(
10411                    &auth_store,
10412                    &principal_id,
10413                    role,
10414                    tenant.as_deref(),
10415                    &username,
10416                    "truncate",
10417                    &q.name,
10418                );
10419            }
10420            // Remaining DDL (#753) — hybrid policy-aware gate. Specific
10421            // create/alter/drop verbs gate operations with a clear
10422            // per-collection target so Red UI can author fine-grained
10423            // policies (`create on collection:users`). Namespace-level
10424            // and grouped DDL fall back to broader `schema:admin` /
10425            // `schema:write` verbs against a `schema:<name>` resource.
10426            // All branches share the [`check_ddl_object_privilege`]
10427            // helper so allows / denies produce the same structured
10428            // "principal=… action=… resource=<kind>:<name> denied by
10429            // IAM policy" reason the Red UI security read contracts
10430            // (#740) already render.
10431            QueryExpr::CreateTable(q) => {
10432                return self.check_ddl_object_privilege(
10433                    &auth_store,
10434                    &principal_id,
10435                    role,
10436                    tenant.as_deref(),
10437                    &username,
10438                    "create",
10439                    "collection",
10440                    &q.name,
10441                    crate::auth::Role::Write,
10442                );
10443            }
10444            QueryExpr::CreateCollection(q) => {
10445                return self.check_ddl_object_privilege(
10446                    &auth_store,
10447                    &principal_id,
10448                    role,
10449                    tenant.as_deref(),
10450                    &username,
10451                    "create",
10452                    "collection",
10453                    &q.name,
10454                    crate::auth::Role::Write,
10455                );
10456            }
10457            QueryExpr::CreateVector(q) => {
10458                return self.check_ddl_object_privilege(
10459                    &auth_store,
10460                    &principal_id,
10461                    role,
10462                    tenant.as_deref(),
10463                    &username,
10464                    "create",
10465                    "collection",
10466                    &q.name,
10467                    crate::auth::Role::Write,
10468                );
10469            }
10470            QueryExpr::AlterTable(q) => {
10471                return self.check_ddl_object_privilege(
10472                    &auth_store,
10473                    &principal_id,
10474                    role,
10475                    tenant.as_deref(),
10476                    &username,
10477                    "alter",
10478                    "collection",
10479                    &q.name,
10480                    crate::auth::Role::Write,
10481                );
10482            }
10483            QueryExpr::CreateIndex(q) => {
10484                return self.check_ddl_object_privilege(
10485                    &auth_store,
10486                    &principal_id,
10487                    role,
10488                    tenant.as_deref(),
10489                    &username,
10490                    "create",
10491                    "collection",
10492                    &q.table,
10493                    crate::auth::Role::Write,
10494                );
10495            }
10496            QueryExpr::DropIndex(q) => {
10497                return self.check_ddl_object_privilege(
10498                    &auth_store,
10499                    &principal_id,
10500                    role,
10501                    tenant.as_deref(),
10502                    &username,
10503                    "drop",
10504                    "collection",
10505                    &q.table,
10506                    crate::auth::Role::Write,
10507                );
10508            }
10509            QueryExpr::CreateSchema(q) => {
10510                return self.check_ddl_object_privilege(
10511                    &auth_store,
10512                    &principal_id,
10513                    role,
10514                    tenant.as_deref(),
10515                    &username,
10516                    "schema:admin",
10517                    "schema",
10518                    &q.name,
10519                    crate::auth::Role::Admin,
10520                );
10521            }
10522            QueryExpr::DropSchema(q) => {
10523                return self.check_ddl_object_privilege(
10524                    &auth_store,
10525                    &principal_id,
10526                    role,
10527                    tenant.as_deref(),
10528                    &username,
10529                    "schema:admin",
10530                    "schema",
10531                    &q.name,
10532                    crate::auth::Role::Admin,
10533                );
10534            }
10535            QueryExpr::CreateSequence(q) => {
10536                return self.check_ddl_object_privilege(
10537                    &auth_store,
10538                    &principal_id,
10539                    role,
10540                    tenant.as_deref(),
10541                    &username,
10542                    "create",
10543                    "collection",
10544                    &q.name,
10545                    crate::auth::Role::Write,
10546                );
10547            }
10548            QueryExpr::DropSequence(q) => {
10549                return self.check_ddl_object_privilege(
10550                    &auth_store,
10551                    &principal_id,
10552                    role,
10553                    tenant.as_deref(),
10554                    &username,
10555                    "drop",
10556                    "collection",
10557                    &q.name,
10558                    crate::auth::Role::Write,
10559                );
10560            }
10561            QueryExpr::CreateView(q) => {
10562                return self.check_ddl_object_privilege(
10563                    &auth_store,
10564                    &principal_id,
10565                    role,
10566                    tenant.as_deref(),
10567                    &username,
10568                    "create",
10569                    "collection",
10570                    &q.name,
10571                    crate::auth::Role::Write,
10572                );
10573            }
10574            QueryExpr::DropView(q) => {
10575                return self.check_ddl_object_privilege(
10576                    &auth_store,
10577                    &principal_id,
10578                    role,
10579                    tenant.as_deref(),
10580                    &username,
10581                    "drop",
10582                    "collection",
10583                    &q.name,
10584                    crate::auth::Role::Write,
10585                );
10586            }
10587            QueryExpr::RefreshMaterializedView(q) => {
10588                return self.check_ddl_object_privilege(
10589                    &auth_store,
10590                    &principal_id,
10591                    role,
10592                    tenant.as_deref(),
10593                    &username,
10594                    "alter",
10595                    "collection",
10596                    &q.name,
10597                    crate::auth::Role::Write,
10598                );
10599            }
10600            QueryExpr::CreatePolicy(q) => {
10601                return self.check_ddl_object_privilege(
10602                    &auth_store,
10603                    &principal_id,
10604                    role,
10605                    tenant.as_deref(),
10606                    &username,
10607                    "create",
10608                    "collection",
10609                    &q.table,
10610                    crate::auth::Role::Write,
10611                );
10612            }
10613            QueryExpr::DropPolicy(q) => {
10614                return self.check_ddl_object_privilege(
10615                    &auth_store,
10616                    &principal_id,
10617                    role,
10618                    tenant.as_deref(),
10619                    &username,
10620                    "drop",
10621                    "collection",
10622                    &q.table,
10623                    crate::auth::Role::Write,
10624                );
10625            }
10626            QueryExpr::CreateServer(q) => {
10627                return self.check_ddl_object_privilege(
10628                    &auth_store,
10629                    &principal_id,
10630                    role,
10631                    tenant.as_deref(),
10632                    &username,
10633                    "schema:admin",
10634                    "schema",
10635                    &q.name,
10636                    crate::auth::Role::Admin,
10637                );
10638            }
10639            QueryExpr::DropServer(q) => {
10640                return self.check_ddl_object_privilege(
10641                    &auth_store,
10642                    &principal_id,
10643                    role,
10644                    tenant.as_deref(),
10645                    &username,
10646                    "schema:admin",
10647                    "schema",
10648                    &q.name,
10649                    crate::auth::Role::Admin,
10650                );
10651            }
10652            QueryExpr::CreateForeignTable(q) => {
10653                return self.check_ddl_object_privilege(
10654                    &auth_store,
10655                    &principal_id,
10656                    role,
10657                    tenant.as_deref(),
10658                    &username,
10659                    "schema:write",
10660                    "schema",
10661                    &q.name,
10662                    crate::auth::Role::Write,
10663                );
10664            }
10665            QueryExpr::DropForeignTable(q) => {
10666                return self.check_ddl_object_privilege(
10667                    &auth_store,
10668                    &principal_id,
10669                    role,
10670                    tenant.as_deref(),
10671                    &username,
10672                    "schema:write",
10673                    "schema",
10674                    &q.name,
10675                    crate::auth::Role::Write,
10676                );
10677            }
10678            QueryExpr::CreateTimeSeries(q) => {
10679                return self.check_ddl_object_privilege(
10680                    &auth_store,
10681                    &principal_id,
10682                    role,
10683                    tenant.as_deref(),
10684                    &username,
10685                    "create",
10686                    "collection",
10687                    &q.name,
10688                    crate::auth::Role::Write,
10689                );
10690            }
10691            QueryExpr::CreateMetric(q) => {
10692                return self.check_ddl_object_privilege(
10693                    &auth_store,
10694                    &principal_id,
10695                    role,
10696                    tenant.as_deref(),
10697                    &username,
10698                    "create",
10699                    "collection",
10700                    &q.path,
10701                    crate::auth::Role::Write,
10702                );
10703            }
10704            QueryExpr::AlterMetric(q) => {
10705                return self.check_ddl_object_privilege(
10706                    &auth_store,
10707                    &principal_id,
10708                    role,
10709                    tenant.as_deref(),
10710                    &username,
10711                    "alter",
10712                    "collection",
10713                    &q.path,
10714                    crate::auth::Role::Write,
10715                );
10716            }
10717            QueryExpr::CreateSlo(q) => {
10718                return self.check_ddl_object_privilege(
10719                    &auth_store,
10720                    &principal_id,
10721                    role,
10722                    tenant.as_deref(),
10723                    &username,
10724                    "create",
10725                    "collection",
10726                    &q.path,
10727                    crate::auth::Role::Write,
10728                );
10729            }
10730            QueryExpr::DropTimeSeries(q) => {
10731                return self.check_ddl_object_privilege(
10732                    &auth_store,
10733                    &principal_id,
10734                    role,
10735                    tenant.as_deref(),
10736                    &username,
10737                    "drop",
10738                    "collection",
10739                    &q.name,
10740                    crate::auth::Role::Write,
10741                );
10742            }
10743            QueryExpr::CreateQueue(q) => {
10744                return self.check_ddl_object_privilege(
10745                    &auth_store,
10746                    &principal_id,
10747                    role,
10748                    tenant.as_deref(),
10749                    &username,
10750                    "create",
10751                    "collection",
10752                    &q.name,
10753                    crate::auth::Role::Write,
10754                );
10755            }
10756            QueryExpr::AlterQueue(q) => {
10757                return self.check_ddl_object_privilege(
10758                    &auth_store,
10759                    &principal_id,
10760                    role,
10761                    tenant.as_deref(),
10762                    &username,
10763                    "alter",
10764                    "collection",
10765                    &q.name,
10766                    crate::auth::Role::Write,
10767                );
10768            }
10769            QueryExpr::DropQueue(q) => {
10770                return self.check_ddl_object_privilege(
10771                    &auth_store,
10772                    &principal_id,
10773                    role,
10774                    tenant.as_deref(),
10775                    &username,
10776                    "drop",
10777                    "collection",
10778                    &q.name,
10779                    crate::auth::Role::Write,
10780                );
10781            }
10782            QueryExpr::CreateTree(q) => {
10783                return self.check_ddl_object_privilege(
10784                    &auth_store,
10785                    &principal_id,
10786                    role,
10787                    tenant.as_deref(),
10788                    &username,
10789                    "create",
10790                    "collection",
10791                    &q.collection,
10792                    crate::auth::Role::Write,
10793                );
10794            }
10795            QueryExpr::DropTree(q) => {
10796                return self.check_ddl_object_privilege(
10797                    &auth_store,
10798                    &principal_id,
10799                    role,
10800                    tenant.as_deref(),
10801                    &username,
10802                    "drop",
10803                    "collection",
10804                    &q.collection,
10805                    crate::auth::Role::Write,
10806                );
10807            }
10808            // Migration DDL — CREATE MIGRATION is grouped DDL on the
10809            // schema namespace; uses the `schema:write` fallback verb
10810            // (no obvious per-collection target).
10811            QueryExpr::CreateMigration(q) => {
10812                return self.check_ddl_object_privilege(
10813                    &auth_store,
10814                    &principal_id,
10815                    role,
10816                    tenant.as_deref(),
10817                    &username,
10818                    "schema:write",
10819                    "schema",
10820                    &q.name,
10821                    crate::auth::Role::Write,
10822                );
10823            }
10824            // APPLY / ROLLBACK change data and schema — require Admin.
10825            QueryExpr::ApplyMigration(_) | QueryExpr::RollbackMigration(_) => {
10826                return if role == crate::auth::Role::Admin {
10827                    Ok(())
10828                } else {
10829                    Err(format!(
10830                        "principal=`{}` role=`{:?}` cannot issue APPLY/ROLLBACK MIGRATION",
10831                        username, role
10832                    ))
10833                };
10834            }
10835            // EXPLAIN MIGRATION is read-only — any authenticated principal.
10836            QueryExpr::ExplainMigration(_) => return Ok(()),
10837            // Everything else (SET, SHOW, transaction control, graph
10838            // commands, queue/tree commands, MaintenanceCommand …)
10839            // is allowed for any authenticated principal.
10840            _ => return Ok(()),
10841        };
10842
10843        if auth_store.iam_authorization_enabled() {
10844            let iam_action = legacy_action_to_iam(action);
10845            let iam_resource = legacy_resource_to_iam(&resource, tenant.as_deref());
10846            let iam_ctx = runtime_iam_context(role, tenant.as_deref());
10847            if !auth_store.check_policy_authz_with_role(
10848                &principal_id,
10849                iam_action,
10850                &iam_resource,
10851                &iam_ctx,
10852                role,
10853            ) {
10854                return Err(format!(
10855                    "principal=`{}` action=`{}` resource=`{}:{}` denied by IAM policy",
10856                    username, iam_action, iam_resource.kind, iam_resource.name
10857                ));
10858            }
10859
10860            if let QueryExpr::Table(table) = expr {
10861                self.check_table_column_projection_privilege(
10862                    &auth_store,
10863                    &principal_id,
10864                    &iam_ctx,
10865                    table,
10866                )?;
10867            }
10868
10869            if let QueryExpr::Update(update) = expr {
10870                let columns = update_set_target_columns(update);
10871                if !columns.is_empty() {
10872                    let request = column_access_request_for_table_update(&update.table, columns);
10873                    let outcome =
10874                        auth_store.check_column_projection_authz(&principal_id, &request, &iam_ctx);
10875                    if let Some(denied) = outcome.first_denied_column() {
10876                        return Err(format!(
10877                            "principal=`{}` action=`{}` resource=`{}:{}` denied by IAM column policy",
10878                            username, iam_action, denied.resource.kind, denied.resource.name
10879                        ));
10880                    }
10881                    if !outcome.allowed() {
10882                        return Err(format!(
10883                            "principal=`{}` action=`{}` resource=`{}:{}` denied by IAM policy",
10884                            username,
10885                            iam_action,
10886                            outcome.table_resource.kind,
10887                            outcome.table_resource.name
10888                        ));
10889                    }
10890                }
10891
10892                if let Some(columns) = update_returning_columns_for_policy(self, update) {
10893                    let request = column_access_request_for_table_select(&update.table, columns);
10894                    let outcome =
10895                        auth_store.check_column_projection_authz(&principal_id, &request, &iam_ctx);
10896                    if let Some(denied) = outcome.first_denied_column() {
10897                        return Err(format!(
10898                            "principal=`{}` action=`select` resource=`{}:{}` denied by IAM column policy",
10899                            username, denied.resource.kind, denied.resource.name
10900                        ));
10901                    }
10902                    if !outcome.allowed() {
10903                        return Err(format!(
10904                            "principal=`{}` action=`select` resource=`{}:{}` denied by IAM policy",
10905                            username, outcome.table_resource.kind, outcome.table_resource.name
10906                        ));
10907                    }
10908                }
10909            }
10910
10911            Ok(())
10912        } else {
10913            auth_store
10914                .check_grant(&ctx, action, &resource)
10915                .map_err(|e| e.to_string())
10916        }
10917    }
10918
10919    fn check_table_column_projection_privilege(
10920        &self,
10921        auth_store: &Arc<crate::auth::store::AuthStore>,
10922        principal: &crate::auth::UserId,
10923        ctx: &crate::auth::policies::EvalContext,
10924        table: &crate::storage::query::ast::TableQuery,
10925    ) -> Result<(), String> {
10926        use crate::auth::{ColumnAccessRequest, ColumnDecisionEffect};
10927
10928        let columns = requested_table_columns_for_policy(table);
10929        if columns.is_empty() {
10930            return Ok(());
10931        }
10932
10933        let request = ColumnAccessRequest::select(table.table.clone(), columns);
10934        let outcome = auth_store.check_column_projection_authz(principal, &request, ctx);
10935        if outcome.allowed() {
10936            return Ok(());
10937        }
10938
10939        if !matches!(
10940            outcome.table_decision,
10941            crate::auth::policies::Decision::Allow { .. }
10942                | crate::auth::policies::Decision::AdminBypass
10943        ) {
10944            return Err(format!(
10945                "principal=`{}` action=`select` resource=`{}:{}` denied by IAM policy",
10946                principal, outcome.table_resource.kind, outcome.table_resource.name
10947            ));
10948        }
10949
10950        let denied = outcome
10951            .first_denied_column()
10952            .filter(|decision| decision.effective == ColumnDecisionEffect::Denied);
10953        match denied {
10954            Some(decision) => Err(format!(
10955                "principal=`{}` action=`select` resource=`{}:{}` denied by IAM policy",
10956                principal, decision.resource.kind, decision.resource.name
10957            )),
10958            None => Ok(()),
10959        }
10960    }
10961
10962    fn check_graph_property_projection_privilege(
10963        &self,
10964        auth_store: &Arc<crate::auth::store::AuthStore>,
10965        principal: &crate::auth::UserId,
10966        role: crate::auth::Role,
10967        tenant: Option<&str>,
10968        query: &crate::storage::query::ast::GraphQuery,
10969    ) -> Result<(), String> {
10970        let columns = explicit_graph_projection_properties(query);
10971        if columns.is_empty() {
10972            return Ok(());
10973        }
10974        self.check_table_like_column_projection_privilege(
10975            auth_store, principal, role, tenant, "graph", &columns,
10976        )
10977    }
10978
10979    fn check_table_like_column_projection_privilege(
10980        &self,
10981        auth_store: &Arc<crate::auth::store::AuthStore>,
10982        principal: &crate::auth::UserId,
10983        role: crate::auth::Role,
10984        tenant: Option<&str>,
10985        table: &str,
10986        columns: &[String],
10987    ) -> Result<(), String> {
10988        let iam_ctx = runtime_iam_context(role, tenant);
10989        let request =
10990            crate::auth::ColumnAccessRequest::select(table.to_string(), columns.iter().cloned());
10991        let outcome = auth_store.check_column_projection_authz(principal, &request, &iam_ctx);
10992        if outcome.allowed() {
10993            return Ok(());
10994        }
10995        let denied = outcome
10996            .first_denied_column()
10997            .map(|d| d.resource.name.clone())
10998            .unwrap_or_else(|| format!("{table}.<unknown>"));
10999        Err(format!(
11000            "principal=`{}` action=`select` resource=`column:{}` denied by IAM policy",
11001            principal, denied
11002        ))
11003    }
11004
11005    fn check_policy_management_privilege(
11006        &self,
11007        auth_store: &Arc<crate::auth::store::AuthStore>,
11008        principal: &crate::auth::UserId,
11009        role: crate::auth::Role,
11010        tenant: Option<&str>,
11011        action: &str,
11012        resource_kind: &str,
11013        resource_name: &str,
11014    ) -> Result<(), String> {
11015        let ctx = runtime_iam_context(role, tenant);
11016
11017        if !auth_store.iam_authorization_enabled() {
11018            return if role == crate::auth::Role::Admin {
11019                Ok(())
11020            } else {
11021                Err(format!(
11022                    "principal=`{}` role=`{:?}` cannot issue ACL/auth DDL",
11023                    principal, role
11024                ))
11025            };
11026        }
11027
11028        if resource_kind == "policy"
11029            && matches!(
11030                action,
11031                "policy:put" | "policy:drop" | "policy:attach" | "policy:detach"
11032            )
11033            && self
11034                .inner
11035                .config_registry
11036                .get_active(resource_name)
11037                .map(|entry| entry.managed)
11038                .unwrap_or(false)
11039        {
11040            return Ok(());
11041        }
11042
11043        let mut resource = crate::auth::policies::ResourceRef::new(
11044            resource_kind.to_string(),
11045            resource_name.to_string(),
11046        );
11047        if let Some(t) = tenant {
11048            resource = resource.with_tenant(t.to_string());
11049        }
11050        if auth_store.check_policy_authz_with_role(principal, action, &resource, &ctx, role) {
11051            Ok(())
11052        } else {
11053            Err(format!(
11054                "principal=`{}` action=`{}` resource=`{}:{}` denied by IAM policy",
11055                principal, action, resource.kind, resource.name
11056            ))
11057        }
11058    }
11059
11060    fn check_managed_config_write_for_set_config(&self, key: &str) -> RedDBResult<()> {
11061        let Some(auth_store) = self.inner.auth_store.read().clone() else {
11062            return Ok(());
11063        };
11064        let (username, role) = current_auth_identity()
11065            .unwrap_or_else(|| ("anonymous".to_string(), crate::auth::Role::Read));
11066        let tenant = current_tenant();
11067        let principal = crate::auth::UserId::from_parts(tenant.as_deref(), &username);
11068        let ctx = runtime_iam_context(role, tenant.as_deref());
11069        let gate = crate::auth::managed_config::ManagedConfigGate::new(
11070            self.inner.config_registry.as_ref(),
11071        );
11072        match gate.check_write(&auth_store, &principal, &ctx, key) {
11073            crate::auth::managed_config::ManagedConfigDecision::PassThrough { .. }
11074            | crate::auth::managed_config::ManagedConfigDecision::Allow { .. } => Ok(()),
11075            crate::auth::managed_config::ManagedConfigDecision::Deny { reason, .. } => {
11076                Err(RedDBError::Query(format!(
11077                    "permission denied: managed config mutation blocked for `{key}`: {reason}"
11078                )))
11079            }
11080        }
11081    }
11082
11083    /// IAM privilege check for a granular queue operation (issue #755 /
11084    /// PRD #735).
11085    ///
11086    /// Each queue operation maps to a stable verb in
11087    /// [`crate::auth::action_catalog`] (`queue:enqueue`, `queue:read`,
11088    /// `queue:peek`, `queue:ack`, `queue:nack`, `queue:retry`,
11089    /// `queue:dlq:move`, `queue:purge`, `queue:presence:read`). The
11090    /// resource is `queue:<name>` scoped to the current tenant. In
11091    /// legacy mode (no IAM authorization configured) the check is a
11092    /// no-op — the role gates in `execute_queue_command` still apply
11093    /// and the legacy `select` / `write` grant table continues to
11094    /// govern queue access. In IAM-enabled mode a missing granular
11095    /// grant yields a structured, UI-safe error of the form
11096    /// `principal=… action=queue:… resource=queue:… denied by IAM
11097    /// policy` so Red UI can surface the failing toolbar action.
11098    fn check_queue_op_privilege(
11099        &self,
11100        auth_store: &Arc<crate::auth::store::AuthStore>,
11101        principal: &crate::auth::UserId,
11102        role: crate::auth::Role,
11103        tenant: Option<&str>,
11104        action: &str,
11105        queue: &str,
11106    ) -> Result<(), String> {
11107        if !auth_store.iam_authorization_enabled() {
11108            return Ok(());
11109        }
11110        let mut resource =
11111            crate::auth::policies::ResourceRef::new("queue".to_string(), queue.to_string());
11112        if let Some(t) = tenant {
11113            resource = resource.with_tenant(t.to_string());
11114        }
11115        let ctx = runtime_iam_context(role, tenant);
11116        if auth_store.check_policy_authz_with_role(principal, action, &resource, &ctx, role) {
11117            Ok(())
11118        } else {
11119            Err(format!(
11120                "principal=`{}` action=`{}` resource=`queue:{}` denied by IAM policy",
11121                principal, action, queue
11122            ))
11123        }
11124    }
11125
11126    /// IAM privilege check for a graph operation (issue #757 / PRD
11127    /// #735).
11128    ///
11129    /// Each graph operation maps to a stable verb in
11130    /// [`crate::auth::action_catalog`] — `graph:read` for
11131    /// metadata/property lookups, `graph:traverse` for MATCH / PATH /
11132    /// NEIGHBORHOOD / TRAVERSE / SHORTEST_PATH, and
11133    /// `graph:algorithm:run` for analytics algorithms (centrality,
11134    /// community, components, cycles, clustering, topological sort).
11135    /// The resource is `graph:*` scoped to the current tenant — the
11136    /// runtime today operates on a singleton graph store so the name
11137    /// has no concrete identifier; policies grant the explorer
11138    /// surface by writing `graph:*` as the resource pattern.
11139    ///
11140    /// In legacy mode (no IAM authorization configured) the check is
11141    /// a no-op so the existing role-based defaults continue to
11142    /// govern. In IAM-enabled mode a missing grant produces the
11143    /// UI-safe envelope `principal=… action=graph:… resource=graph:*
11144    /// denied by IAM policy` Red UI keys on.
11145    fn check_graph_op_privilege(
11146        &self,
11147        auth_store: &Arc<crate::auth::store::AuthStore>,
11148        principal: &crate::auth::UserId,
11149        role: crate::auth::Role,
11150        tenant: Option<&str>,
11151        action: &str,
11152    ) -> Result<(), String> {
11153        if !auth_store.iam_authorization_enabled() {
11154            return Ok(());
11155        }
11156        let mut resource =
11157            crate::auth::policies::ResourceRef::new("graph".to_string(), "*".to_string());
11158        if let Some(t) = tenant {
11159            resource = resource.with_tenant(t.to_string());
11160        }
11161        let ctx = runtime_iam_context(role, tenant);
11162        if auth_store.check_policy_authz_with_role(principal, action, &resource, &ctx, role) {
11163            Ok(())
11164        } else {
11165            Err(format!(
11166                "principal=`{}` action=`{}` resource=`graph:*` denied by IAM policy",
11167                principal, action
11168            ))
11169        }
11170    }
11171
11172    /// IAM privilege check for a granular vector operation (issue #756
11173    /// / PRD #735).
11174    ///
11175    /// Each vector operation maps to a stable verb in
11176    /// [`crate::auth::action_catalog`] (`vector:read`, `vector:search`,
11177    /// `vector:artifact:read`, `vector:artifact:rebuild`,
11178    /// `vector:admin`). The resource is `vector:<collection>` scoped to
11179    /// the current tenant. In legacy mode (no IAM authorization
11180    /// configured) the check is a no-op — the role gates and existing
11181    /// `select` / column-projection grants continue to govern access.
11182    /// In IAM-enabled mode a missing granular grant yields a
11183    /// structured, UI-safe error of the form `principal=…
11184    /// action=vector:… resource=vector:… denied by IAM policy` so Red
11185    /// UI can surface the failing toolbar action.
11186    fn check_vector_op_privilege(
11187        &self,
11188        auth_store: &Arc<crate::auth::store::AuthStore>,
11189        principal: &crate::auth::UserId,
11190        role: crate::auth::Role,
11191        tenant: Option<&str>,
11192        action: &str,
11193        collection: &str,
11194    ) -> Result<(), String> {
11195        if !auth_store.iam_authorization_enabled() {
11196            return Ok(());
11197        }
11198        let mut resource =
11199            crate::auth::policies::ResourceRef::new("vector".to_string(), collection.to_string());
11200        if let Some(t) = tenant {
11201            resource = resource.with_tenant(t.to_string());
11202        }
11203        let ctx = runtime_iam_context(role, tenant);
11204        if auth_store.check_policy_authz_with_role(principal, action, &resource, &ctx, role) {
11205            Ok(())
11206        } else {
11207            Err(format!(
11208                "principal=`{}` action=`{}` resource=`vector:{}` denied by IAM policy",
11209                principal, action, collection
11210            ))
11211        }
11212    }
11213
11214    /// IAM privilege check for DROP / TRUNCATE on a named collection.
11215    ///
11216    /// Delegates to [`check_ddl_object_privilege`] with `resource_kind =
11217    /// "collection"`. Kept as a thin wrapper so the existing DROP/TRUNCATE
11218    /// callsites stay readable.
11219    fn check_ddl_collection_privilege(
11220        &self,
11221        auth_store: &Arc<crate::auth::store::AuthStore>,
11222        principal: &crate::auth::UserId,
11223        role: crate::auth::Role,
11224        tenant: Option<&str>,
11225        username: &str,
11226        action: &str,
11227        collection: &str,
11228    ) -> Result<(), String> {
11229        self.check_ddl_object_privilege(
11230            auth_store,
11231            principal,
11232            role,
11233            tenant,
11234            username,
11235            action,
11236            "collection",
11237            collection,
11238            crate::auth::Role::Write,
11239        )
11240    }
11241
11242    /// Generalised IAM privilege check for DDL on a named object.
11243    ///
11244    /// `action` is the stable verb advertised through the action catalog
11245    /// (`create`, `alter`, `drop`, `truncate`, `schema:write`,
11246    /// `schema:admin`). `resource_kind` / `resource_name` form the policy
11247    /// resource (`collection:<name>`, `schema:<name>`). `min_role` is the
11248    /// legacy gate when IAM is not yet enabled.
11249    ///
11250    /// Behaviour:
11251    /// * Role below `min_role` → structured "principal=… role=… cannot
11252    ///   issue DDL" denial, audit recorded.
11253    /// * IAM disabled → audit-record success and allow (legacy path).
11254    /// * IAM enabled → call `check_policy_authz_with_role`. Explicit Deny
11255    ///   and DefaultDeny in PolicyOnly mode both produce a UI-safe
11256    ///   "principal=… action=… resource=<kind>:<name> denied by IAM
11257    ///   policy" string. Explicit Allow and the LegacyRbac fallback
11258    ///   allow the action.
11259    #[allow(clippy::too_many_arguments)]
11260    fn check_ddl_object_privilege(
11261        &self,
11262        auth_store: &Arc<crate::auth::store::AuthStore>,
11263        principal: &crate::auth::UserId,
11264        role: crate::auth::Role,
11265        tenant: Option<&str>,
11266        username: &str,
11267        action: &str,
11268        resource_kind: &str,
11269        resource_name: &str,
11270        min_role: crate::auth::Role,
11271    ) -> Result<(), String> {
11272        if role < min_role {
11273            let msg = format!(
11274                "principal=`{}` role=`{:?}` cannot issue DDL action=`{}` resource=`{}:{}`",
11275                username, role, action, resource_kind, resource_name
11276            );
11277            self.inner.audit_log.record(
11278                action,
11279                username,
11280                resource_name,
11281                "denied",
11282                crate::json::Value::Null,
11283            );
11284            return Err(msg);
11285        }
11286
11287        if !auth_store.iam_authorization_enabled() {
11288            self.inner.audit_log.record(
11289                action,
11290                username,
11291                resource_name,
11292                "ok",
11293                crate::json::Value::Null,
11294            );
11295            return Ok(());
11296        }
11297
11298        let mut resource = crate::auth::policies::ResourceRef::new(
11299            resource_kind.to_string(),
11300            resource_name.to_string(),
11301        );
11302        if let Some(t) = tenant {
11303            resource = resource.with_tenant(t.to_string());
11304        }
11305        let ctx = runtime_iam_context(role, tenant);
11306        if auth_store.check_policy_authz_with_role(principal, action, &resource, &ctx, role) {
11307            self.inner.audit_log.record(
11308                action,
11309                username,
11310                resource_name,
11311                "ok",
11312                crate::json::Value::Null,
11313            );
11314            Ok(())
11315        } else {
11316            self.inner.audit_log.record(
11317                action,
11318                username,
11319                resource_name,
11320                "denied",
11321                crate::json::Value::Null,
11322            );
11323            Err(format!(
11324                "principal=`{}` action=`{}` resource=`{}:{}` denied by IAM policy",
11325                username, action, resource_kind, resource_name
11326            ))
11327        }
11328    }
11329
11330    /// Translate the parsed [`GrantStmt`] into AuthStore mutations.
11331    fn execute_grant_statement(
11332        &self,
11333        query: &str,
11334        stmt: &crate::storage::query::ast::GrantStmt,
11335    ) -> RedDBResult<RuntimeQueryResult> {
11336        use crate::auth::privileges::{Action, GrantPrincipal, Resource};
11337        use crate::auth::UserId;
11338        use crate::storage::query::ast::{GrantObjectKind, GrantPrincipalRef};
11339
11340        let auth_store = self
11341            .inner
11342            .auth_store
11343            .read()
11344            .clone()
11345            .ok_or_else(|| RedDBError::Query("auth store not configured".to_string()))?;
11346
11347        // Granter identity + role.
11348        let (gname, grole) = current_auth_identity().ok_or_else(|| {
11349            RedDBError::Query("GRANT requires an authenticated principal".to_string())
11350        })?;
11351        let granter = UserId::from_parts(current_tenant().as_deref(), &gname);
11352        let granter_role = grole;
11353
11354        // Build the action set.
11355        let mut actions: Vec<Action> = Vec::new();
11356        if stmt.all {
11357            actions.push(Action::All);
11358        } else {
11359            for kw in &stmt.actions {
11360                let a = Action::from_keyword(kw).ok_or_else(|| {
11361                    RedDBError::Query(format!("unknown privilege keyword `{}`", kw))
11362                })?;
11363                actions.push(a);
11364            }
11365        }
11366
11367        // Audit emit (printed; structured emission is Agent #4's lane).
11368        let mut applied = 0usize;
11369        for obj in &stmt.objects {
11370            let resource = match stmt.object_kind {
11371                GrantObjectKind::Table => Resource::Table {
11372                    schema: obj.schema.clone(),
11373                    table: obj.name.clone(),
11374                },
11375                GrantObjectKind::Schema => Resource::Schema(obj.name.clone()),
11376                GrantObjectKind::Database => Resource::Database,
11377                GrantObjectKind::Function => Resource::Function {
11378                    schema: obj.schema.clone(),
11379                    name: obj.name.clone(),
11380                },
11381            };
11382            for principal in &stmt.principals {
11383                let p = match principal {
11384                    GrantPrincipalRef::Public => GrantPrincipal::Public,
11385                    GrantPrincipalRef::Group(g) => GrantPrincipal::Group(g.clone()),
11386                    GrantPrincipalRef::User { tenant, name } => {
11387                        GrantPrincipal::User(UserId::from_parts(tenant.as_deref(), name))
11388                    }
11389                };
11390                // Tenant of the grant follows the granter's tenant
11391                // (cross-tenant guard inside `AuthStore::grant`).
11392                let tenant = granter.tenant.clone();
11393                auth_store
11394                    .grant(
11395                        &granter,
11396                        granter_role,
11397                        p.clone(),
11398                        resource.clone(),
11399                        actions.clone(),
11400                        stmt.with_grant_option,
11401                        tenant.clone(),
11402                    )
11403                    .map_err(|e| RedDBError::Query(e.to_string()))?;
11404
11405                // IAM policy translation: every GRANT also lands as a
11406                // synthetic `_grant_<id>` policy attached to the
11407                // principal so the new evaluator sees it.
11408                if let Some(policy) =
11409                    grant_to_iam_policy(&p, &resource, &actions, tenant.as_deref())
11410                {
11411                    let pid = policy.id.clone();
11412                    auth_store
11413                        .put_policy_internal(policy)
11414                        .map_err(|e| RedDBError::Query(e.to_string()))?;
11415                    let attachment = match &p {
11416                        GrantPrincipal::User(uid) => {
11417                            crate::auth::store::PrincipalRef::User(uid.clone())
11418                        }
11419                        GrantPrincipal::Group(group) => {
11420                            crate::auth::store::PrincipalRef::Group(group.clone())
11421                        }
11422                        GrantPrincipal::Public => crate::auth::store::PrincipalRef::Group(
11423                            crate::auth::store::PUBLIC_IAM_GROUP.to_string(),
11424                        ),
11425                    };
11426                    auth_store
11427                        .attach_policy(attachment, &pid)
11428                        .map_err(|e| RedDBError::Query(e.to_string()))?;
11429                }
11430                applied += 1;
11431                tracing::info!(
11432                    target: "audit",
11433                    principal = %granter,
11434                    action = "grant",
11435                    "GRANT applied"
11436                );
11437            }
11438        }
11439
11440        self.invalidate_result_cache();
11441        Ok(RuntimeQueryResult::ok_message(
11442            query.to_string(),
11443            &format!("GRANT applied to {} target(s)", applied),
11444            "grant",
11445        ))
11446    }
11447
11448    /// Translate the parsed [`RevokeStmt`] into AuthStore mutations.
11449    fn execute_revoke_statement(
11450        &self,
11451        query: &str,
11452        stmt: &crate::storage::query::ast::RevokeStmt,
11453    ) -> RedDBResult<RuntimeQueryResult> {
11454        use crate::auth::privileges::{Action, GrantPrincipal, Resource};
11455        use crate::auth::UserId;
11456        use crate::storage::query::ast::{GrantObjectKind, GrantPrincipalRef};
11457
11458        let auth_store = self
11459            .inner
11460            .auth_store
11461            .read()
11462            .clone()
11463            .ok_or_else(|| RedDBError::Query("auth store not configured".to_string()))?;
11464
11465        let (_gname, grole) = current_auth_identity().ok_or_else(|| {
11466            RedDBError::Query("REVOKE requires an authenticated principal".to_string())
11467        })?;
11468        let granter_role = grole;
11469
11470        let actions: Vec<Action> = if stmt.all {
11471            vec![Action::All]
11472        } else {
11473            stmt.actions
11474                .iter()
11475                .map(|kw| Action::from_keyword(kw).unwrap_or(Action::Select))
11476                .collect()
11477        };
11478
11479        let mut total_removed = 0usize;
11480        for obj in &stmt.objects {
11481            let resource = match stmt.object_kind {
11482                GrantObjectKind::Table => Resource::Table {
11483                    schema: obj.schema.clone(),
11484                    table: obj.name.clone(),
11485                },
11486                GrantObjectKind::Schema => Resource::Schema(obj.name.clone()),
11487                GrantObjectKind::Database => Resource::Database,
11488                GrantObjectKind::Function => Resource::Function {
11489                    schema: obj.schema.clone(),
11490                    name: obj.name.clone(),
11491                },
11492            };
11493            for principal in &stmt.principals {
11494                let p = match principal {
11495                    GrantPrincipalRef::Public => GrantPrincipal::Public,
11496                    GrantPrincipalRef::Group(g) => GrantPrincipal::Group(g.clone()),
11497                    GrantPrincipalRef::User { tenant, name } => {
11498                        GrantPrincipal::User(UserId::from_parts(tenant.as_deref(), name))
11499                    }
11500                };
11501                let removed = auth_store
11502                    .revoke(granter_role, &p, &resource, &actions)
11503                    .map_err(|e| RedDBError::Query(e.to_string()))?;
11504                let _removed_policies =
11505                    auth_store.delete_synthetic_grant_policies(&p, &resource, &actions);
11506                total_removed += removed;
11507            }
11508        }
11509
11510        self.invalidate_result_cache();
11511        Ok(RuntimeQueryResult::ok_message(
11512            query.to_string(),
11513            &format!("REVOKE removed {} grant(s)", total_removed),
11514            "revoke",
11515        ))
11516    }
11517
11518    /// Translate the parsed [`CreateUserStmt`] into an AuthStore user.
11519    fn execute_create_user_statement(
11520        &self,
11521        query: &str,
11522        stmt: &crate::storage::query::ast::CreateUserStmt,
11523    ) -> RedDBResult<RuntimeQueryResult> {
11524        let auth_store = self
11525            .inner
11526            .auth_store
11527            .read()
11528            .clone()
11529            .ok_or_else(|| RedDBError::Query("auth store not configured".to_string()))?;
11530
11531        let (_gname, grole) = current_auth_identity().ok_or_else(|| {
11532            RedDBError::Query("CREATE USER requires an authenticated principal".to_string())
11533        })?;
11534        if grole != crate::auth::Role::Admin {
11535            return Err(RedDBError::Query(
11536                "CREATE USER requires Admin role".to_string(),
11537            ));
11538        }
11539
11540        let role = crate::auth::Role::from_str(&stmt.role)
11541            .ok_or_else(|| RedDBError::Query(format!("invalid role `{}`", stmt.role)))?;
11542        let user = auth_store
11543            .create_user_in_tenant(stmt.tenant.as_deref(), &stmt.username, &stmt.password, role)
11544            .map_err(|e| RedDBError::Query(e.to_string()))?;
11545
11546        self.invalidate_result_cache();
11547        let target = crate::auth::UserId::from_parts(user.tenant_id.as_deref(), &user.username);
11548        tracing::info!(
11549            target: "audit",
11550            principal = %target,
11551            role = %role,
11552            action = "create_user",
11553            "CREATE USER applied"
11554        );
11555
11556        Ok(RuntimeQueryResult::ok_message(
11557            query.to_string(),
11558            &format!("CREATE USER {} applied", target),
11559            "create_user",
11560        ))
11561    }
11562
11563    /// Translate the parsed [`AlterUserStmt`] into AuthStore mutations.
11564    fn execute_alter_user_statement(
11565        &self,
11566        query: &str,
11567        stmt: &crate::storage::query::ast::AlterUserStmt,
11568    ) -> RedDBResult<RuntimeQueryResult> {
11569        use crate::auth::privileges::UserAttributes;
11570        use crate::auth::UserId;
11571        use crate::storage::query::ast::AlterUserAttribute;
11572
11573        let auth_store = self
11574            .inner
11575            .auth_store
11576            .read()
11577            .clone()
11578            .ok_or_else(|| RedDBError::Query("auth store not configured".to_string()))?;
11579
11580        let (_gname, grole) = current_auth_identity().ok_or_else(|| {
11581            RedDBError::Query("ALTER USER requires an authenticated principal".to_string())
11582        })?;
11583        if grole != crate::auth::Role::Admin {
11584            return Err(RedDBError::Query(
11585                "ALTER USER requires Admin role".to_string(),
11586            ));
11587        }
11588
11589        let target = UserId::from_parts(stmt.tenant.as_deref(), &stmt.username);
11590
11591        // Apply attributes incrementally — each one reads the current
11592        // record, mutates the relevant field, writes back.
11593        let mut attrs = auth_store.user_attributes(&target);
11594        let mut enable_change: Option<bool> = None;
11595
11596        for a in &stmt.attributes {
11597            match a {
11598                AlterUserAttribute::ValidUntil(ts) => {
11599                    // Parse ISO-ish timestamp → ms since epoch. Fall
11600                    // back to integer-ms parsing for callers that pass
11601                    // `'1234567890123'`.
11602                    let ms = parse_timestamp_to_ms(ts).ok_or_else(|| {
11603                        RedDBError::Query(format!("invalid VALID UNTIL timestamp `{ts}`"))
11604                    })?;
11605                    attrs.valid_until = Some(ms);
11606                }
11607                AlterUserAttribute::ConnectionLimit(n) => {
11608                    if *n < 0 {
11609                        return Err(RedDBError::Query(
11610                            "CONNECTION LIMIT must be non-negative".to_string(),
11611                        ));
11612                    }
11613                    attrs.connection_limit = Some(*n as u32);
11614                }
11615                AlterUserAttribute::SetSearchPath(p) => {
11616                    attrs.search_path = Some(p.clone());
11617                }
11618                AlterUserAttribute::AddGroup(g) => {
11619                    if !attrs.groups.iter().any(|existing| existing == g) {
11620                        attrs.groups.push(g.clone());
11621                        attrs.groups.sort();
11622                    }
11623                }
11624                AlterUserAttribute::DropGroup(g) => {
11625                    attrs.groups.retain(|existing| existing != g);
11626                }
11627                AlterUserAttribute::Enable => enable_change = Some(true),
11628                AlterUserAttribute::Disable => enable_change = Some(false),
11629                AlterUserAttribute::Password(_) => {
11630                    // Out of scope — accept the AST but no-op so the
11631                    // parser stays compatible with future password
11632                    // rotation work.
11633                }
11634            }
11635        }
11636
11637        auth_store
11638            .set_user_attributes(&target, attrs)
11639            .map_err(|e| RedDBError::Query(e.to_string()))?;
11640        if let Some(en) = enable_change {
11641            auth_store
11642                .set_user_enabled(&target, en)
11643                .map_err(|e| RedDBError::Query(e.to_string()))?;
11644        }
11645        self.invalidate_result_cache();
11646        tracing::info!(
11647            target: "audit",
11648            principal = %target,
11649            action = "alter_user",
11650            "ALTER USER applied"
11651        );
11652
11653        Ok(RuntimeQueryResult::ok_message(
11654            query.to_string(),
11655            &format!("ALTER USER {} applied", target),
11656            "alter_user",
11657        ))
11658    }
11659
11660    // -----------------------------------------------------------------
11661    // IAM policy executors
11662    // -----------------------------------------------------------------
11663
11664    fn execute_create_iam_policy(
11665        &self,
11666        query: &str,
11667        id: &str,
11668        json: &str,
11669    ) -> RedDBResult<RuntimeQueryResult> {
11670        use crate::auth::policies::Policy;
11671
11672        let auth_store = self
11673            .inner
11674            .auth_store
11675            .read()
11676            .clone()
11677            .ok_or_else(|| RedDBError::Query("auth store not configured".to_string()))?;
11678
11679        // Parse + validate. The kernel rejects oversize / bad shape /
11680        // bad action keywords. If the supplied id differs from the JSON
11681        // id, override it with the SQL-provided id (the JSON id is
11682        // optional context — the SQL DDL form is authoritative).
11683        let mut policy = Policy::from_json_str(json)
11684            .map_err(|e| RedDBError::Query(format!("policy parse: {e}")))?;
11685        if policy.id != id {
11686            policy.id = id.to_string();
11687        }
11688        let pid = policy.id.clone();
11689        let tenant = current_tenant();
11690        let (actor_name, actor_role) = current_auth_identity()
11691            .unwrap_or_else(|| ("anonymous".to_string(), crate::auth::Role::Read));
11692        let actor = crate::auth::UserId::from_parts(tenant.as_deref(), &actor_name);
11693        let eval_ctx = runtime_iam_context(actor_role, tenant.as_deref());
11694        let event_ctx = self.policy_mutation_control_ctx(&actor, tenant.as_deref());
11695        let ledger = self.inner.control_event_ledger.read();
11696        let control = crate::auth::store::PolicyMutationControl {
11697            ctx: &event_ctx,
11698            ledger: ledger.as_ref(),
11699            config: self.inner.control_event_config,
11700            registry: Some(self.inner.config_registry.as_ref()),
11701            actor: &actor,
11702            eval_ctx: &eval_ctx,
11703        };
11704        auth_store
11705            .put_policy_with_control_events(policy, &control)
11706            .map_err(|e| RedDBError::Query(e.to_string()))?;
11707
11708        let principal = actor_name;
11709        tracing::info!(
11710            target: "audit",
11711            principal = %principal,
11712            action = "iam:policy.put",
11713            matched_policy_id = %pid,
11714            "CREATE POLICY applied"
11715        );
11716        self.inner.audit_log.record(
11717            "iam/policy.put",
11718            &principal,
11719            &pid,
11720            "ok",
11721            crate::json::Value::Null,
11722        );
11723
11724        self.invalidate_result_cache();
11725        Ok(RuntimeQueryResult::ok_message(
11726            query.to_string(),
11727            &format!("policy `{pid}` stored"),
11728            "create_iam_policy",
11729        ))
11730    }
11731
11732    fn execute_drop_iam_policy(&self, query: &str, id: &str) -> RedDBResult<RuntimeQueryResult> {
11733        let auth_store = self
11734            .inner
11735            .auth_store
11736            .read()
11737            .clone()
11738            .ok_or_else(|| RedDBError::Query("auth store not configured".to_string()))?;
11739        let tenant = current_tenant();
11740        let (actor_name, actor_role) = current_auth_identity()
11741            .unwrap_or_else(|| ("anonymous".to_string(), crate::auth::Role::Read));
11742        let actor = crate::auth::UserId::from_parts(tenant.as_deref(), &actor_name);
11743        let eval_ctx = runtime_iam_context(actor_role, tenant.as_deref());
11744        let event_ctx = self.policy_mutation_control_ctx(&actor, tenant.as_deref());
11745        let ledger = self.inner.control_event_ledger.read();
11746        let control = crate::auth::store::PolicyMutationControl {
11747            ctx: &event_ctx,
11748            ledger: ledger.as_ref(),
11749            config: self.inner.control_event_config,
11750            registry: Some(self.inner.config_registry.as_ref()),
11751            actor: &actor,
11752            eval_ctx: &eval_ctx,
11753        };
11754        auth_store
11755            .delete_policy_with_control_events(id, &control)
11756            .map_err(|e| RedDBError::Query(e.to_string()))?;
11757
11758        let principal = actor_name;
11759        tracing::info!(
11760            target: "audit",
11761            principal = %principal,
11762            action = "iam:policy.drop",
11763            matched_policy_id = %id,
11764            "DROP POLICY applied"
11765        );
11766        self.inner.audit_log.record(
11767            "iam/policy.drop",
11768            &principal,
11769            id,
11770            "ok",
11771            crate::json::Value::Null,
11772        );
11773
11774        self.invalidate_result_cache();
11775        Ok(RuntimeQueryResult::ok_message(
11776            query.to_string(),
11777            &format!("policy `{id}` dropped"),
11778            "drop_iam_policy",
11779        ))
11780    }
11781
11782    fn execute_attach_policy(
11783        &self,
11784        query: &str,
11785        policy_id: &str,
11786        principal: &crate::storage::query::ast::PolicyPrincipalRef,
11787    ) -> RedDBResult<RuntimeQueryResult> {
11788        use crate::auth::store::PrincipalRef;
11789        use crate::auth::UserId;
11790        use crate::storage::query::ast::PolicyPrincipalRef;
11791
11792        let auth_store = self
11793            .inner
11794            .auth_store
11795            .read()
11796            .clone()
11797            .ok_or_else(|| RedDBError::Query("auth store not configured".to_string()))?;
11798        let p = match principal {
11799            PolicyPrincipalRef::User(u) => {
11800                PrincipalRef::User(UserId::from_parts(u.tenant.as_deref(), &u.username))
11801            }
11802            PolicyPrincipalRef::Group(g) => PrincipalRef::Group(g.clone()),
11803        };
11804        let pretty_target = principal_label(principal);
11805        let tenant = current_tenant();
11806        let (actor_name, actor_role) = current_auth_identity()
11807            .unwrap_or_else(|| ("anonymous".to_string(), crate::auth::Role::Read));
11808        let actor = crate::auth::UserId::from_parts(tenant.as_deref(), &actor_name);
11809        let eval_ctx = runtime_iam_context(actor_role, tenant.as_deref());
11810        let event_ctx = self.policy_mutation_control_ctx(&actor, tenant.as_deref());
11811        let ledger = self.inner.control_event_ledger.read();
11812        let control = crate::auth::store::PolicyMutationControl {
11813            ctx: &event_ctx,
11814            ledger: ledger.as_ref(),
11815            config: self.inner.control_event_config,
11816            registry: Some(self.inner.config_registry.as_ref()),
11817            actor: &actor,
11818            eval_ctx: &eval_ctx,
11819        };
11820        auth_store
11821            .attach_policy_with_control_events(p, policy_id, &control)
11822            .map_err(|e| RedDBError::Query(e.to_string()))?;
11823
11824        let principal_str = actor_name;
11825        tracing::info!(
11826            target: "audit",
11827            principal = %principal_str,
11828            action = "iam:policy.attach",
11829            matched_policy_id = %policy_id,
11830            target = %pretty_target,
11831            "ATTACH POLICY applied"
11832        );
11833        self.inner.audit_log.record(
11834            "iam/policy.attach",
11835            &principal_str,
11836            &pretty_target,
11837            "ok",
11838            crate::json::Value::Null,
11839        );
11840
11841        self.invalidate_result_cache();
11842        Ok(RuntimeQueryResult::ok_message(
11843            query.to_string(),
11844            &format!("policy `{policy_id}` attached to {pretty_target}"),
11845            "attach_policy",
11846        ))
11847    }
11848
11849    fn execute_detach_policy(
11850        &self,
11851        query: &str,
11852        policy_id: &str,
11853        principal: &crate::storage::query::ast::PolicyPrincipalRef,
11854    ) -> RedDBResult<RuntimeQueryResult> {
11855        use crate::auth::store::PrincipalRef;
11856        use crate::auth::UserId;
11857        use crate::storage::query::ast::PolicyPrincipalRef;
11858
11859        let auth_store = self
11860            .inner
11861            .auth_store
11862            .read()
11863            .clone()
11864            .ok_or_else(|| RedDBError::Query("auth store not configured".to_string()))?;
11865        let p = match principal {
11866            PolicyPrincipalRef::User(u) => {
11867                PrincipalRef::User(UserId::from_parts(u.tenant.as_deref(), &u.username))
11868            }
11869            PolicyPrincipalRef::Group(g) => PrincipalRef::Group(g.clone()),
11870        };
11871        let pretty_target = principal_label(principal);
11872        let tenant = current_tenant();
11873        let (actor_name, actor_role) = current_auth_identity()
11874            .unwrap_or_else(|| ("anonymous".to_string(), crate::auth::Role::Read));
11875        let actor = crate::auth::UserId::from_parts(tenant.as_deref(), &actor_name);
11876        let eval_ctx = runtime_iam_context(actor_role, tenant.as_deref());
11877        let event_ctx = self.policy_mutation_control_ctx(&actor, tenant.as_deref());
11878        let ledger = self.inner.control_event_ledger.read();
11879        let control = crate::auth::store::PolicyMutationControl {
11880            ctx: &event_ctx,
11881            ledger: ledger.as_ref(),
11882            config: self.inner.control_event_config,
11883            registry: Some(self.inner.config_registry.as_ref()),
11884            actor: &actor,
11885            eval_ctx: &eval_ctx,
11886        };
11887        auth_store
11888            .detach_policy_with_control_events(p, policy_id, &control)
11889            .map_err(|e| RedDBError::Query(e.to_string()))?;
11890
11891        let principal_str = actor_name;
11892        tracing::info!(
11893            target: "audit",
11894            principal = %principal_str,
11895            action = "iam:policy.detach",
11896            matched_policy_id = %policy_id,
11897            target = %pretty_target,
11898            "DETACH POLICY applied"
11899        );
11900        self.inner.audit_log.record(
11901            "iam/policy.detach",
11902            &principal_str,
11903            &pretty_target,
11904            "ok",
11905            crate::json::Value::Null,
11906        );
11907
11908        self.invalidate_result_cache();
11909        Ok(RuntimeQueryResult::ok_message(
11910            query.to_string(),
11911            &format!("policy `{policy_id}` detached from {pretty_target}"),
11912            "detach_policy",
11913        ))
11914    }
11915
11916    fn execute_show_policies(
11917        &self,
11918        query: &str,
11919        filter: Option<&crate::storage::query::ast::PolicyPrincipalRef>,
11920    ) -> RedDBResult<RuntimeQueryResult> {
11921        use crate::auth::UserId;
11922        use crate::storage::query::ast::PolicyPrincipalRef;
11923        use crate::storage::query::unified::UnifiedRecord;
11924        use crate::storage::schema::Value as SchemaValue;
11925        use std::sync::Arc;
11926
11927        let auth_store = self
11928            .inner
11929            .auth_store
11930            .read()
11931            .clone()
11932            .ok_or_else(|| RedDBError::Query("auth store not configured".to_string()))?;
11933
11934        let pols = match filter {
11935            None => auth_store.list_policies(),
11936            Some(PolicyPrincipalRef::User(u)) => {
11937                let id = UserId::from_parts(u.tenant.as_deref(), &u.username);
11938                auth_store.effective_policies(&id)
11939            }
11940            Some(PolicyPrincipalRef::Group(g)) => auth_store.group_policies(g),
11941        };
11942
11943        let mut records = Vec::with_capacity(pols.len() + 1);
11944
11945        // Header row (#712 / S5A): synthetic record at index 0 that
11946        // reports the active PolicyEnforcementMode and the hard-cutover
11947        // version, so an operator running SHOW POLICIES can see the
11948        // current posture without a separate command.
11949        let mode = auth_store.enforcement_mode();
11950        let mut header = UnifiedRecord::default();
11951        header.set_arc(
11952            Arc::from("id"),
11953            SchemaValue::text("<enforcement_mode>".to_string()),
11954        );
11955        header.set_arc(Arc::from("statements"), SchemaValue::Integer(0));
11956        header.set_arc(Arc::from("tenant"), SchemaValue::Null);
11957        let header_json = format!(
11958            r#"{{"enforcement_mode":"{}","policy_only_hard_version":"{}"}}"#,
11959            mode.as_str(),
11960            crate::auth::enforcement_mode::POLICY_ONLY_HARD_VERSION
11961        );
11962        header.set_arc(Arc::from("json"), SchemaValue::text(header_json));
11963        records.push(header);
11964
11965        for p in pols.iter() {
11966            let mut rec = UnifiedRecord::default();
11967            rec.set_arc(Arc::from("id"), SchemaValue::text(p.id.clone()));
11968            rec.set_arc(
11969                Arc::from("statements"),
11970                SchemaValue::Integer(p.statements.len() as i64),
11971            );
11972            rec.set_arc(
11973                Arc::from("tenant"),
11974                p.tenant
11975                    .as_deref()
11976                    .map(|t| SchemaValue::text(t.to_string()))
11977                    .unwrap_or(SchemaValue::Null),
11978            );
11979            rec.set_arc(Arc::from("json"), SchemaValue::text(p.to_json_string()));
11980            records.push(rec);
11981        }
11982        let mut result = crate::storage::query::unified::UnifiedResult::empty();
11983        result.records = records;
11984        Ok(RuntimeQueryResult {
11985            query: query.to_string(),
11986            mode: crate::storage::query::modes::QueryMode::Sql,
11987            statement: "show_policies",
11988            engine: "iam-policies",
11989            result,
11990            affected_rows: 0,
11991            statement_type: "select",
11992            bookmark: None,
11993        })
11994    }
11995
11996    fn execute_show_effective_permissions(
11997        &self,
11998        query: &str,
11999        user: &crate::storage::query::ast::PolicyUserRef,
12000        resource: Option<&crate::storage::query::ast::PolicyResourceRef>,
12001    ) -> RedDBResult<RuntimeQueryResult> {
12002        use crate::auth::UserId;
12003        use crate::storage::query::unified::UnifiedRecord;
12004        use crate::storage::schema::Value as SchemaValue;
12005        use std::sync::Arc;
12006
12007        let auth_store = self
12008            .inner
12009            .auth_store
12010            .read()
12011            .clone()
12012            .ok_or_else(|| RedDBError::Query("auth store not configured".to_string()))?;
12013        let id = UserId::from_parts(user.tenant.as_deref(), &user.username);
12014        let pols = auth_store.effective_policies(&id);
12015
12016        // Show one row per (policy, statement) tuple, plus any
12017        // resource-level filter passed by the caller.
12018        let mut records = Vec::new();
12019        for p in pols.iter() {
12020            for (idx, st) in p.statements.iter().enumerate() {
12021                if let Some(_r) = resource {
12022                    // Naive filter: render statement targets to strings
12023                    // and skip if no match. Conservative default = include
12024                    // (the simulator handles fine-grained matching).
12025                }
12026                let mut rec = UnifiedRecord::default();
12027                rec.set_arc(Arc::from("policy_id"), SchemaValue::text(p.id.clone()));
12028                rec.set_arc(
12029                    Arc::from("statement_index"),
12030                    SchemaValue::Integer(idx as i64),
12031                );
12032                rec.set_arc(
12033                    Arc::from("sid"),
12034                    st.sid
12035                        .as_deref()
12036                        .map(|s| SchemaValue::text(s.to_string()))
12037                        .unwrap_or(SchemaValue::Null),
12038                );
12039                rec.set_arc(
12040                    Arc::from("effect"),
12041                    SchemaValue::text(match st.effect {
12042                        crate::auth::policies::Effect::Allow => "allow",
12043                        crate::auth::policies::Effect::Deny => "deny",
12044                    }),
12045                );
12046                rec.set_arc(
12047                    Arc::from("actions"),
12048                    SchemaValue::Integer(st.actions.len() as i64),
12049                );
12050                rec.set_arc(
12051                    Arc::from("resources"),
12052                    SchemaValue::Integer(st.resources.len() as i64),
12053                );
12054                records.push(rec);
12055            }
12056        }
12057        let mut result = crate::storage::query::unified::UnifiedResult::empty();
12058        result.records = records;
12059        Ok(RuntimeQueryResult {
12060            query: query.to_string(),
12061            mode: crate::storage::query::modes::QueryMode::Sql,
12062            statement: "show_effective_permissions",
12063            engine: "iam-policies",
12064            result,
12065            affected_rows: 0,
12066            statement_type: "select",
12067            bookmark: None,
12068        })
12069    }
12070
12071    fn execute_lint_policy(
12072        &self,
12073        query: &str,
12074        source: &crate::storage::query::ast::LintPolicySource,
12075    ) -> RedDBResult<RuntimeQueryResult> {
12076        use crate::auth::policy_linter::lint;
12077        use crate::storage::query::ast::LintPolicySource;
12078        use crate::storage::query::unified::UnifiedRecord;
12079        use crate::storage::schema::Value as SchemaValue;
12080        use std::sync::Arc;
12081
12082        // Resolve the policy text. `JSON` source lints the literal
12083        // verbatim; `Id` source fetches the stored document so
12084        // operators can lint a policy by name without rebuilding the
12085        // JSON from `SHOW POLICY`.
12086        let policy_text = match source {
12087            LintPolicySource::Json(text) => text.clone(),
12088            LintPolicySource::Id(id) => {
12089                let auth_store =
12090                    self.inner.auth_store.read().clone().ok_or_else(|| {
12091                        RedDBError::Query("auth store not configured".to_string())
12092                    })?;
12093                let policy = auth_store
12094                    .get_policy(id)
12095                    .ok_or_else(|| RedDBError::Query(format!("policy `{id}` not found")))?;
12096                policy.to_json_string()
12097            }
12098        };
12099        let diagnostics = lint(&policy_text);
12100
12101        let principal_str = current_auth_identity()
12102            .map(|(u, _)| u)
12103            .unwrap_or_else(|| "anonymous".into());
12104        tracing::info!(
12105            target: "audit",
12106            principal = %principal_str,
12107            action = "iam:policy.lint",
12108            diagnostic_count = diagnostics.len(),
12109            "LINT POLICY issued"
12110        );
12111        self.inner.audit_log.record(
12112            "iam/policy.lint",
12113            &principal_str,
12114            match source {
12115                LintPolicySource::Id(id) => id.as_str(),
12116                LintPolicySource::Json(_) => "<json>",
12117            },
12118            "ok",
12119            crate::json::Value::Null,
12120        );
12121
12122        // One row per diagnostic. Column order matches the HTTP
12123        // surface's JSON keys so the two contracts line up.
12124        const COLUMNS: [&str; 5] = ["severity", "code", "message", "suggested_fix", "location"];
12125        let schema = Arc::new(
12126            COLUMNS
12127                .iter()
12128                .map(|name| Arc::<str>::from(*name))
12129                .collect::<Vec<_>>(),
12130        );
12131        let records: Vec<UnifiedRecord> = diagnostics
12132            .iter()
12133            .map(|d| {
12134                UnifiedRecord::with_schema(
12135                    Arc::clone(&schema),
12136                    vec![
12137                        SchemaValue::text(d.severity.as_str()),
12138                        SchemaValue::text(d.code.as_str()),
12139                        SchemaValue::text(d.message.clone()),
12140                        d.suggested_fix
12141                            .as_deref()
12142                            .map(SchemaValue::text)
12143                            .unwrap_or(SchemaValue::Null),
12144                        d.location
12145                            .as_deref()
12146                            .map(SchemaValue::text)
12147                            .unwrap_or(SchemaValue::Null),
12148                    ],
12149                )
12150            })
12151            .collect();
12152        let mut result = crate::storage::query::unified::UnifiedResult::with_columns(
12153            COLUMNS.iter().map(|c| c.to_string()).collect(),
12154        );
12155        result.records = records;
12156        Ok(RuntimeQueryResult {
12157            query: query.to_string(),
12158            mode: crate::storage::query::modes::QueryMode::Sql,
12159            statement: "lint_policy",
12160            engine: "iam-policies",
12161            result,
12162            affected_rows: 0,
12163            statement_type: "select",
12164            bookmark: None,
12165        })
12166    }
12167
12168    /// `MIGRATE POLICY MODE TO '<target>' [DRY RUN]` — flip the install
12169    /// from `legacy_rbac` to `policy_only` after the pre-flight delta
12170    /// simulator confirms no non-admin principal would lose access.
12171    /// Issue #714.
12172    fn execute_migrate_policy_mode(
12173        &self,
12174        query: &str,
12175        target: &str,
12176        dry_run: bool,
12177    ) -> RedDBResult<RuntimeQueryResult> {
12178        use crate::auth::enforcement_mode::PolicyEnforcementMode;
12179        use crate::auth::migrate_policy_mode::{
12180            principal_label, simulate_migration_delta, MigratePolicyDelta,
12181        };
12182        use crate::auth::policies::ResourceRef;
12183        use crate::storage::query::unified::UnifiedRecord;
12184        use crate::storage::schema::Value as SchemaValue;
12185        use std::sync::Arc;
12186
12187        // Only `policy_only` is a meaningful destination for this
12188        // command — flipping back to `legacy_rbac` is supported via
12189        // direct config writes (it doesn't need a pre-flight). We
12190        // reject everything else with the same allowlist `parse` uses.
12191        let parsed = PolicyEnforcementMode::parse(target).ok_or_else(|| {
12192            RedDBError::Query(format!(
12193                "MIGRATE POLICY MODE: invalid target `{target}` (expected `policy_only`)"
12194            ))
12195        })?;
12196        if parsed != PolicyEnforcementMode::PolicyOnly {
12197            return Err(RedDBError::Query(format!(
12198                "MIGRATE POLICY MODE: target `{target}` is not supported — only `policy_only` may be migrated to via this command"
12199            )));
12200        }
12201
12202        let auth_store = self
12203            .inner
12204            .auth_store
12205            .read()
12206            .clone()
12207            .ok_or_else(|| RedDBError::Query("auth store not configured".to_string()))?;
12208
12209        // Resource enumeration: every existing collection probed as
12210        // `table:<name>`. This is the realistic resource surface for
12211        // the legacy_rbac fallback (the role floors gate per-table
12212        // actions). Wildcard / column-scoped resources are still
12213        // covered by the policy evaluator because evaluate() resolves
12214        // resource patterns relative to the concrete resources we
12215        // probe here.
12216        let snapshot = self.inner.db.catalog_model_snapshot();
12217        let resources: Vec<ResourceRef> = snapshot
12218            .collections
12219            .iter()
12220            .map(|c| ResourceRef::new("table", c.name.clone()))
12221            .collect();
12222
12223        let now_ms = crate::utils::now_unix_millis() as u128;
12224        let deltas: Vec<MigratePolicyDelta> =
12225            simulate_migration_delta(auth_store.as_ref(), &resources, now_ms);
12226
12227        let principal_str = current_auth_identity()
12228            .map(|(u, _)| u)
12229            .unwrap_or_else(|| "anonymous".into());
12230
12231        // Audit every issuance. The outcome line differentiates
12232        // dry-run, refused, and applied — operators can grep for these
12233        // strings in the audit log.
12234        let outcome_str = if dry_run {
12235            "dry_run"
12236        } else if deltas.is_empty() {
12237            "applied"
12238        } else {
12239            "refused"
12240        };
12241        tracing::info!(
12242            target: "audit",
12243            principal = %principal_str,
12244            action = "iam:policy.migrate_mode",
12245            target = %target,
12246            dry_run,
12247            delta_count = deltas.len(),
12248            outcome = outcome_str,
12249            "MIGRATE POLICY MODE issued"
12250        );
12251        self.inner.audit_log.record(
12252            "iam/policy.migrate_mode",
12253            &principal_str,
12254            target,
12255            outcome_str,
12256            crate::json::Value::Null,
12257        );
12258
12259        // Refuse the non-dry-run path when any principal would lose
12260        // access. The error string carries a compact summary plus the
12261        // delta count so operators can re-run with DRY RUN to inspect.
12262        if !dry_run && !deltas.is_empty() {
12263            let summary = deltas
12264                .iter()
12265                .take(5)
12266                .map(|d| {
12267                    format!(
12268                        "{}:{}/{}:{}",
12269                        principal_label(&d.principal),
12270                        d.action,
12271                        d.resource_kind,
12272                        d.resource_name
12273                    )
12274                })
12275                .collect::<Vec<_>>()
12276                .join(", ");
12277            let more = if deltas.len() > 5 {
12278                format!(" (and {} more)", deltas.len() - 5)
12279            } else {
12280                String::new()
12281            };
12282            return Err(RedDBError::Query(format!(
12283                "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}",
12284                n = deltas.len(),
12285            )));
12286        }
12287
12288        // Mutate the live enforcement mode only on the non-dry-run
12289        // path with an empty delta. `set_enforcement_mode` also
12290        // persists to vault_kv so the new mode survives restart.
12291        if !dry_run {
12292            auth_store.set_enforcement_mode(parsed);
12293        }
12294
12295        const COLUMNS: [&str; 5] = [
12296            "principal",
12297            "role",
12298            "action",
12299            "resource_kind",
12300            "resource_name",
12301        ];
12302        let schema = Arc::new(
12303            COLUMNS
12304                .iter()
12305                .map(|name| Arc::<str>::from(*name))
12306                .collect::<Vec<_>>(),
12307        );
12308        let records: Vec<UnifiedRecord> = deltas
12309            .iter()
12310            .map(|d| {
12311                UnifiedRecord::with_schema(
12312                    Arc::clone(&schema),
12313                    vec![
12314                        SchemaValue::text(principal_label(&d.principal)),
12315                        SchemaValue::text(d.role.as_str()),
12316                        SchemaValue::text(d.action.clone()),
12317                        SchemaValue::text(d.resource_kind.clone()),
12318                        SchemaValue::text(d.resource_name.clone()),
12319                    ],
12320                )
12321            })
12322            .collect();
12323        let mut result = crate::storage::query::unified::UnifiedResult::with_columns(
12324            COLUMNS.iter().map(|c| c.to_string()).collect(),
12325        );
12326        result.records = records;
12327        Ok(RuntimeQueryResult {
12328            query: query.to_string(),
12329            mode: crate::storage::query::modes::QueryMode::Sql,
12330            statement: "migrate_policy_mode",
12331            engine: "iam-policies",
12332            result,
12333            affected_rows: 0,
12334            statement_type: "select",
12335            bookmark: None,
12336        })
12337    }
12338
12339    fn execute_simulate_policy(
12340        &self,
12341        query: &str,
12342        user: &crate::storage::query::ast::PolicyUserRef,
12343        action: &str,
12344        resource: &crate::storage::query::ast::PolicyResourceRef,
12345    ) -> RedDBResult<RuntimeQueryResult> {
12346        use crate::auth::policies::ResourceRef;
12347        use crate::auth::store::SimCtx;
12348        use crate::auth::UserId;
12349        use crate::storage::query::unified::UnifiedRecord;
12350        use crate::storage::schema::Value as SchemaValue;
12351        use std::sync::Arc;
12352
12353        let auth_store = self
12354            .inner
12355            .auth_store
12356            .read()
12357            .clone()
12358            .ok_or_else(|| RedDBError::Query("auth store not configured".to_string()))?;
12359        let id = UserId::from_parts(user.tenant.as_deref(), &user.username);
12360        let r = ResourceRef::new(resource.kind.clone(), resource.name.clone());
12361        let outcome = auth_store.simulate(&id, action, &r, SimCtx::default());
12362
12363        let principal_str = current_auth_identity()
12364            .map(|(u, _)| u)
12365            .unwrap_or_else(|| "anonymous".into());
12366        let (decision_str, matched_pid, matched_sid) = decision_to_strings(&outcome.decision);
12367        tracing::info!(
12368            target: "audit",
12369            principal = %principal_str,
12370            action = "iam:policy.simulate",
12371            decision = %decision_str,
12372            matched_policy_id = ?matched_pid,
12373            matched_sid = ?matched_sid,
12374            "SIMULATE issued"
12375        );
12376        self.inner.audit_log.record(
12377            "iam/policy.simulate",
12378            &principal_str,
12379            &id.to_string(),
12380            "ok",
12381            crate::json::Value::Null,
12382        );
12383
12384        let mut rec = UnifiedRecord::default();
12385        rec.set_arc(Arc::from("decision"), SchemaValue::text(decision_str));
12386        rec.set_arc(
12387            Arc::from("matched_policy_id"),
12388            matched_pid
12389                .map(SchemaValue::text)
12390                .unwrap_or(SchemaValue::Null),
12391        );
12392        rec.set_arc(
12393            Arc::from("matched_sid"),
12394            matched_sid
12395                .map(SchemaValue::text)
12396                .unwrap_or(SchemaValue::Null),
12397        );
12398        rec.set_arc(Arc::from("reason"), SchemaValue::text(outcome.reason));
12399        rec.set_arc(
12400            Arc::from("trail_len"),
12401            SchemaValue::Integer(outcome.trail.len() as i64),
12402        );
12403        let mut result = crate::storage::query::unified::UnifiedResult::empty();
12404        result.records = vec![rec];
12405        Ok(RuntimeQueryResult {
12406            query: query.to_string(),
12407            mode: crate::storage::query::modes::QueryMode::Sql,
12408            statement: "simulate_policy",
12409            engine: "iam-policies",
12410            result,
12411            affected_rows: 0,
12412            statement_type: "select",
12413            bookmark: None,
12414        })
12415    }
12416}
12417
12418/// Translate a parsed GRANT into a synthetic IAM policy whose id
12419/// starts with `_grant_<unique>`. PUBLIC is represented as an
12420/// implicit IAM group; legacy GROUP grants are still rejected by the
12421/// grant store and are not translated here.
12422fn grant_to_iam_policy(
12423    principal: &crate::auth::privileges::GrantPrincipal,
12424    resource: &crate::auth::privileges::Resource,
12425    actions: &[crate::auth::privileges::Action],
12426    tenant: Option<&str>,
12427) -> Option<crate::auth::policies::Policy> {
12428    use crate::auth::policies::{
12429        compile_action, ActionPattern, Effect, Policy, ResourcePattern, Statement,
12430    };
12431    use crate::auth::privileges::{Action, GrantPrincipal, Resource};
12432
12433    if matches!(principal, GrantPrincipal::Group(_)) {
12434        return None;
12435    }
12436
12437    let now = crate::auth::now_ms();
12438    let id = format!("_grant_{:x}_{:x}", now, std::process::id());
12439
12440    let resource_str = match resource {
12441        Resource::Database => "table:*".to_string(),
12442        Resource::Schema(s) => format!("table:{s}.*"),
12443        Resource::Table { schema, table } => match schema {
12444            Some(s) => format!("table:{s}.{table}"),
12445            None => format!("table:{table}"),
12446        },
12447        Resource::Function { schema, name } => match schema {
12448            Some(s) => format!("function:{s}.{name}"),
12449            None => format!("function:{name}"),
12450        },
12451    };
12452
12453    // Compile actions — fall back to `*` only when the grant included
12454    // `Action::All`. Map every other action keyword to its lowercase
12455    // form so it lines up with the kernel's allowlist.
12456    let action_patterns: Vec<ActionPattern> = if actions.contains(&Action::All) {
12457        vec![ActionPattern::Wildcard]
12458    } else {
12459        actions
12460            .iter()
12461            .map(|a| compile_action(&a.as_str().to_ascii_lowercase()))
12462            .collect()
12463    };
12464    if action_patterns.is_empty() {
12465        return None;
12466    }
12467
12468    // Inline resource compilation matching the kernel's `compile_resource`:
12469    //   * `*` → wildcard
12470    //   * contains `*` → glob
12471    //   * `kind:name` → exact
12472    let resource_patterns = if resource_str == "*" {
12473        vec![ResourcePattern::Wildcard]
12474    } else if resource_str.contains('*') {
12475        vec![ResourcePattern::Glob(resource_str.clone())]
12476    } else if let Some((kind, name)) = resource_str.split_once(':') {
12477        vec![ResourcePattern::Exact {
12478            kind: kind.to_string(),
12479            name: name.to_string(),
12480        }]
12481    } else {
12482        vec![ResourcePattern::Wildcard]
12483    };
12484
12485    let policy = Policy {
12486        id,
12487        version: 1,
12488        tenant: tenant.map(|t| t.to_string()),
12489        created_at: now,
12490        updated_at: now,
12491        statements: vec![Statement {
12492            sid: None,
12493            effect: Effect::Allow,
12494            actions: action_patterns,
12495            resources: resource_patterns,
12496            condition: None,
12497        }],
12498    };
12499    if policy.validate().is_err() {
12500        return None;
12501    }
12502    Some(policy)
12503}
12504
12505/// Coerce a `key => <number>` table-function named argument into a positive
12506/// iteration count for the centrality TVFs (issue #797). The parser lexes all
12507/// named values as `f64`, so an integral, finite, strictly-positive value is
12508/// required here; anything else (fractional, zero, negative, NaN/inf) is a
12509/// clear query error. `func` names the function for the message.
12510fn parse_positive_iterations(func: &str, value: &f64) -> RedDBResult<usize> {
12511    if !value.is_finite() || *value < 1.0 || value.fract() != 0.0 {
12512        return Err(RedDBError::Query(format!(
12513            "table function '{func}' max_iterations must be a positive integer, got {value}"
12514        )));
12515    }
12516    Ok(*value as usize)
12517}
12518
12519fn legacy_action_to_iam(action: crate::auth::privileges::Action) -> &'static str {
12520    use crate::auth::privileges::Action;
12521    match action {
12522        Action::Select => "select",
12523        Action::Insert => "insert",
12524        Action::Update => "update",
12525        Action::Delete => "delete",
12526        Action::Truncate => "truncate",
12527        Action::References => "references",
12528        Action::Execute => "execute",
12529        Action::Usage => "usage",
12530        Action::All => "*",
12531    }
12532}
12533
12534fn update_set_target_columns(query: &crate::storage::query::ast::UpdateQuery) -> Vec<String> {
12535    let mut columns = Vec::new();
12536    for (column, _) in &query.assignment_exprs {
12537        if !columns.iter().any(|seen| seen == column) {
12538            columns.push(column.clone());
12539        }
12540    }
12541    columns
12542}
12543
12544fn column_access_request_for_table_update(
12545    table_name: &str,
12546    columns: Vec<String>,
12547) -> crate::auth::ColumnAccessRequest {
12548    match table_name.split_once('.') {
12549        Some((schema, table)) => {
12550            crate::auth::ColumnAccessRequest::update(table.to_string(), columns)
12551                .with_schema(schema.to_string())
12552        }
12553        None => crate::auth::ColumnAccessRequest::update(table_name.to_string(), columns),
12554    }
12555}
12556
12557fn column_access_request_for_table_select(
12558    table_name: &str,
12559    columns: Vec<String>,
12560) -> crate::auth::ColumnAccessRequest {
12561    match table_name.split_once('.') {
12562        Some((schema, table)) => {
12563            crate::auth::ColumnAccessRequest::select(table.to_string(), columns)
12564                .with_schema(schema.to_string())
12565        }
12566        None => crate::auth::ColumnAccessRequest::select(table_name.to_string(), columns),
12567    }
12568}
12569
12570fn update_returning_columns_for_policy(
12571    runtime: &RedDBRuntime,
12572    query: &crate::storage::query::ast::UpdateQuery,
12573) -> Option<Vec<String>> {
12574    let items = query.returning.as_ref()?;
12575    let mut columns = Vec::new();
12576    let project_all = items
12577        .iter()
12578        .any(|item| matches!(item, crate::storage::query::ast::ReturningItem::All));
12579    if project_all {
12580        collect_returning_star_columns(runtime, query, &mut columns);
12581    } else {
12582        for item in items {
12583            let crate::storage::query::ast::ReturningItem::Column(column) = item else {
12584                continue;
12585            };
12586            push_returning_policy_column(&mut columns, column);
12587        }
12588    }
12589    (!columns.is_empty()).then_some(columns)
12590}
12591
12592fn collect_returning_star_columns(
12593    runtime: &RedDBRuntime,
12594    query: &crate::storage::query::ast::UpdateQuery,
12595    columns: &mut Vec<String>,
12596) {
12597    let store = runtime.db().store();
12598    let Some(manager) = store.get_collection(&query.table) else {
12599        return;
12600    };
12601    if let Some(schema) = manager.column_schema() {
12602        for column in schema.iter() {
12603            push_returning_policy_column(columns, column);
12604        }
12605    }
12606    for entity in manager.query_all(|_| true) {
12607        if !returning_entity_matches_update_target(&entity, query.target) {
12608            continue;
12609        }
12610        match &entity.data {
12611            crate::storage::EntityData::Row(row) => {
12612                for (column, _) in row.iter_fields() {
12613                    push_returning_policy_column(columns, column);
12614                }
12615            }
12616            crate::storage::EntityData::Node(node) => {
12617                push_returning_policy_column(columns, "label");
12618                push_returning_policy_column(columns, "node_type");
12619                for column in node.properties.keys() {
12620                    push_returning_policy_column(columns, column);
12621                }
12622            }
12623            crate::storage::EntityData::Edge(edge) => {
12624                push_returning_policy_column(columns, "label");
12625                push_returning_policy_column(columns, "from_rid");
12626                push_returning_policy_column(columns, "to_rid");
12627                push_returning_policy_column(columns, "weight");
12628                for column in edge.properties.keys() {
12629                    push_returning_policy_column(columns, column);
12630                }
12631            }
12632            _ => {}
12633        }
12634    }
12635}
12636
12637fn push_returning_policy_column(columns: &mut Vec<String>, column: &str) {
12638    if returning_public_envelope_column(column) {
12639        return;
12640    }
12641    if !columns.iter().any(|seen| seen == column) {
12642        columns.push(column.to_string());
12643    }
12644}
12645
12646fn returning_public_envelope_column(column: &str) -> bool {
12647    matches!(
12648        column.to_ascii_lowercase().as_str(),
12649        "rid" | "collection" | "kind" | "tenant" | "created_at" | "updated_at"
12650    )
12651}
12652
12653fn returning_entity_matches_update_target(
12654    entity: &crate::storage::UnifiedEntity,
12655    target: crate::storage::query::ast::UpdateTarget,
12656) -> bool {
12657    use crate::storage::query::ast::UpdateTarget;
12658    match target {
12659        UpdateTarget::Rows => {
12660            matches!(returning_row_item_kind(entity), Some(ReturningRowKind::Row))
12661        }
12662        UpdateTarget::Documents => {
12663            matches!(
12664                returning_row_item_kind(entity),
12665                Some(ReturningRowKind::Document)
12666            )
12667        }
12668        UpdateTarget::Kv => matches!(returning_row_item_kind(entity), Some(ReturningRowKind::Kv)),
12669        UpdateTarget::Nodes => matches!(
12670            (&entity.kind, &entity.data),
12671            (
12672                crate::storage::EntityKind::GraphNode(_),
12673                crate::storage::EntityData::Node(_)
12674            )
12675        ),
12676        UpdateTarget::Edges => matches!(
12677            (&entity.kind, &entity.data),
12678            (
12679                crate::storage::EntityKind::GraphEdge(_),
12680                crate::storage::EntityData::Edge(_)
12681            )
12682        ),
12683    }
12684}
12685
12686#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12687enum ReturningRowKind {
12688    Row,
12689    Document,
12690    Kv,
12691}
12692
12693fn returning_row_item_kind(entity: &crate::storage::UnifiedEntity) -> Option<ReturningRowKind> {
12694    let row = entity.data.as_row()?;
12695    let is_kv = row.iter_fields().all(|(column, _)| {
12696        column.eq_ignore_ascii_case("key") || column.eq_ignore_ascii_case("value")
12697    });
12698    if is_kv {
12699        return Some(ReturningRowKind::Kv);
12700    }
12701    let is_document = row
12702        .iter_fields()
12703        .any(|(_, value)| matches!(value, crate::storage::schema::Value::Json(_)));
12704    if is_document {
12705        Some(ReturningRowKind::Document)
12706    } else {
12707        Some(ReturningRowKind::Row)
12708    }
12709}
12710
12711fn requested_table_columns_for_policy(
12712    table: &crate::storage::query::ast::TableQuery,
12713) -> Vec<String> {
12714    use crate::storage::query::sql_lowering::{
12715        effective_table_filter, effective_table_group_by_exprs, effective_table_having_filter,
12716        effective_table_projections,
12717    };
12718
12719    let table_name = table.table.as_str();
12720    let table_alias = table.alias.as_deref();
12721    let mut columns = std::collections::BTreeSet::new();
12722
12723    for projection in effective_table_projections(table) {
12724        collect_projection_columns(&projection, table_name, table_alias, &mut columns);
12725    }
12726    if let Some(filter) = effective_table_filter(table) {
12727        collect_filter_columns(&filter, table_name, table_alias, &mut columns);
12728    }
12729    for expr in effective_table_group_by_exprs(table) {
12730        collect_expr_columns(&expr, table_name, table_alias, &mut columns);
12731    }
12732    if let Some(filter) = effective_table_having_filter(table) {
12733        collect_filter_columns(&filter, table_name, table_alias, &mut columns);
12734    }
12735    for order in &table.order_by {
12736        if let Some(expr) = order.expr.as_ref() {
12737            collect_expr_columns(expr, table_name, table_alias, &mut columns);
12738        } else {
12739            collect_field_ref_column(&order.field, table_name, table_alias, &mut columns);
12740        }
12741    }
12742
12743    columns.into_iter().collect()
12744}
12745
12746fn collect_projection_columns(
12747    projection: &crate::storage::query::ast::Projection,
12748    table_name: &str,
12749    table_alias: Option<&str>,
12750    columns: &mut std::collections::BTreeSet<String>,
12751) {
12752    use crate::storage::query::ast::Projection;
12753    match projection {
12754        Projection::All => {
12755            columns.insert("*".to_string());
12756        }
12757        Projection::Column(column) | Projection::Alias(column, _) => {
12758            if column != "*" {
12759                columns.insert(column.clone());
12760            }
12761        }
12762        Projection::Function(_, args) => {
12763            for arg in args {
12764                collect_projection_columns(arg, table_name, table_alias, columns);
12765            }
12766        }
12767        Projection::Expression(filter, _) => {
12768            collect_filter_columns(filter, table_name, table_alias, columns);
12769        }
12770        Projection::Field(field, _) => {
12771            collect_field_ref_column(field, table_name, table_alias, columns);
12772        }
12773        // Slice 7a (#589): no runtime support yet; recurse into args so
12774        // any column references are still tracked in case a future
12775        // executor needs the column set.
12776        Projection::Window { args, .. } => {
12777            for arg in args {
12778                collect_projection_columns(arg, table_name, table_alias, columns);
12779            }
12780        }
12781    }
12782}
12783
12784fn collect_filter_columns(
12785    filter: &crate::storage::query::ast::Filter,
12786    table_name: &str,
12787    table_alias: Option<&str>,
12788    columns: &mut std::collections::BTreeSet<String>,
12789) {
12790    use crate::storage::query::ast::Filter;
12791    match filter {
12792        Filter::Compare { field, .. }
12793        | Filter::IsNull(field)
12794        | Filter::IsNotNull(field)
12795        | Filter::In { field, .. }
12796        | Filter::Between { field, .. }
12797        | Filter::Like { field, .. }
12798        | Filter::StartsWith { field, .. }
12799        | Filter::EndsWith { field, .. }
12800        | Filter::Contains { field, .. } => {
12801            collect_field_ref_column(field, table_name, table_alias, columns);
12802        }
12803        Filter::CompareFields { left, right, .. } => {
12804            collect_field_ref_column(left, table_name, table_alias, columns);
12805            collect_field_ref_column(right, table_name, table_alias, columns);
12806        }
12807        Filter::CompareExpr { lhs, rhs, .. } => {
12808            collect_expr_columns(lhs, table_name, table_alias, columns);
12809            collect_expr_columns(rhs, table_name, table_alias, columns);
12810        }
12811        Filter::And(left, right) | Filter::Or(left, right) => {
12812            collect_filter_columns(left, table_name, table_alias, columns);
12813            collect_filter_columns(right, table_name, table_alias, columns);
12814        }
12815        Filter::Not(inner) => collect_filter_columns(inner, table_name, table_alias, columns),
12816    }
12817}
12818
12819fn collect_expr_columns(
12820    expr: &crate::storage::query::ast::Expr,
12821    table_name: &str,
12822    table_alias: Option<&str>,
12823    columns: &mut std::collections::BTreeSet<String>,
12824) {
12825    use crate::storage::query::ast::Expr;
12826    match expr {
12827        Expr::Column { field, .. } => {
12828            collect_field_ref_column(field, table_name, table_alias, columns);
12829        }
12830        Expr::Literal { .. } | Expr::Parameter { .. } => {}
12831        Expr::UnaryOp { operand, .. } | Expr::Cast { inner: operand, .. } => {
12832            collect_expr_columns(operand, table_name, table_alias, columns);
12833        }
12834        Expr::BinaryOp { lhs, rhs, .. } => {
12835            collect_expr_columns(lhs, table_name, table_alias, columns);
12836            collect_expr_columns(rhs, table_name, table_alias, columns);
12837        }
12838        Expr::FunctionCall { args, .. } => {
12839            for arg in args {
12840                collect_expr_columns(arg, table_name, table_alias, columns);
12841            }
12842        }
12843        Expr::Case {
12844            branches, else_, ..
12845        } => {
12846            for (condition, value) in branches {
12847                collect_expr_columns(condition, table_name, table_alias, columns);
12848                collect_expr_columns(value, table_name, table_alias, columns);
12849            }
12850            if let Some(value) = else_ {
12851                collect_expr_columns(value, table_name, table_alias, columns);
12852            }
12853        }
12854        Expr::IsNull { operand, .. } => {
12855            collect_expr_columns(operand, table_name, table_alias, columns);
12856        }
12857        Expr::InList { target, values, .. } => {
12858            collect_expr_columns(target, table_name, table_alias, columns);
12859            for value in values {
12860                collect_expr_columns(value, table_name, table_alias, columns);
12861            }
12862        }
12863        Expr::Between {
12864            target, low, high, ..
12865        } => {
12866            collect_expr_columns(target, table_name, table_alias, columns);
12867            collect_expr_columns(low, table_name, table_alias, columns);
12868            collect_expr_columns(high, table_name, table_alias, columns);
12869        }
12870        Expr::Subquery { .. } => {}
12871        Expr::WindowFunctionCall { args, window, .. } => {
12872            for arg in args {
12873                collect_expr_columns(arg, table_name, table_alias, columns);
12874            }
12875            for e in &window.partition_by {
12876                collect_expr_columns(e, table_name, table_alias, columns);
12877            }
12878            for o in &window.order_by {
12879                collect_expr_columns(&o.expr, table_name, table_alias, columns);
12880            }
12881        }
12882    }
12883}
12884
12885fn collect_field_ref_column(
12886    field: &crate::storage::query::ast::FieldRef,
12887    table_name: &str,
12888    table_alias: Option<&str>,
12889    columns: &mut std::collections::BTreeSet<String>,
12890) {
12891    if let Some(column) = policy_column_name_from_field_ref(field, table_name, table_alias) {
12892        if column != "*" {
12893            columns.insert(column);
12894        }
12895    }
12896}
12897
12898fn policy_column_name_from_field_ref(
12899    field: &crate::storage::query::ast::FieldRef,
12900    table_name: &str,
12901    table_alias: Option<&str>,
12902) -> Option<String> {
12903    match field {
12904        crate::storage::query::ast::FieldRef::TableColumn { table, column } => {
12905            if column == "*" {
12906                return Some("*".to_string());
12907            }
12908            if table.is_empty() || table == table_name || Some(table.as_str()) == table_alias {
12909                Some(column.clone())
12910            } else {
12911                Some(format!("{table}.{column}"))
12912            }
12913        }
12914        _ => None,
12915    }
12916}
12917
12918fn legacy_resource_to_iam(
12919    resource: &crate::auth::privileges::Resource,
12920    tenant: Option<&str>,
12921) -> crate::auth::policies::ResourceRef {
12922    use crate::auth::privileges::Resource;
12923
12924    let (kind, name) = match resource {
12925        Resource::Database => ("database".to_string(), "*".to_string()),
12926        Resource::Schema(s) => ("schema".to_string(), format!("{s}.*")),
12927        Resource::Table { schema, table } => (
12928            "table".to_string(),
12929            match schema {
12930                Some(s) => format!("{s}.{table}"),
12931                None => table.clone(),
12932            },
12933        ),
12934        Resource::Function { schema, name } => (
12935            "function".to_string(),
12936            match schema {
12937                Some(s) => format!("{s}.{name}"),
12938                None => name.clone(),
12939            },
12940        ),
12941    };
12942
12943    let mut out = crate::auth::policies::ResourceRef::new(kind, name);
12944    if let Some(t) = tenant {
12945        out = out.with_tenant(t.to_string());
12946    }
12947    out
12948}
12949
12950#[derive(Debug)]
12951struct JoinTableSide {
12952    table: String,
12953    alias: String,
12954}
12955
12956fn table_side_context(expr: &QueryExpr) -> Option<JoinTableSide> {
12957    match expr {
12958        QueryExpr::Table(table) => Some(JoinTableSide {
12959            table: table.table.clone(),
12960            alias: table.alias.clone().unwrap_or_else(|| table.table.clone()),
12961        }),
12962        _ => None,
12963    }
12964}
12965
12966fn collect_projection_columns_for_table(
12967    projection: &Projection,
12968    table: &str,
12969    alias: Option<&str>,
12970    out: &mut BTreeSet<String>,
12971) {
12972    match projection {
12973        Projection::Column(column) | Projection::Alias(column, _) => {
12974            match split_qualified_column(column) {
12975                Some((qualifier, column))
12976                    if qualifier == table || alias.is_some_and(|alias| qualifier == alias) =>
12977                {
12978                    push_policy_column(column, out);
12979                }
12980                Some(_) => {}
12981                None => push_policy_column(column, out),
12982            }
12983        }
12984        Projection::Field(
12985            FieldRef::TableColumn {
12986                table: qualifier,
12987                column,
12988            },
12989            _,
12990        ) => {
12991            if qualifier.is_empty()
12992                || qualifier == table
12993                || alias.is_some_and(|alias| qualifier == alias)
12994            {
12995                push_policy_column(column, out);
12996            }
12997        }
12998        Projection::Field(
12999            FieldRef::NodeProperty {
13000                alias: qualifier,
13001                property,
13002            },
13003            _,
13004        )
13005        | Projection::Field(
13006            FieldRef::EdgeProperty {
13007                alias: qualifier,
13008                property,
13009            },
13010            _,
13011        ) => {
13012            if qualifier == table || alias.is_some_and(|alias| qualifier == alias) {
13013                push_policy_column(property, out);
13014            }
13015        }
13016        Projection::Function(_, args) => {
13017            for arg in args {
13018                collect_projection_columns_for_table(arg, table, alias, out);
13019            }
13020        }
13021        Projection::Expression(_, _) | Projection::All | Projection::Field(_, _) => {}
13022        Projection::Window { args, .. } => {
13023            for arg in args {
13024                collect_projection_columns_for_table(arg, table, alias, out);
13025            }
13026        }
13027    }
13028}
13029
13030fn collect_projection_columns_for_join_side(
13031    projection: &Projection,
13032    left: Option<&JoinTableSide>,
13033    right: Option<&JoinTableSide>,
13034    out: &mut HashMap<String, BTreeSet<String>>,
13035) -> RedDBResult<()> {
13036    match projection {
13037        Projection::Column(column) | Projection::Alias(column, _) => {
13038            if let Some((qualifier, column)) = split_qualified_column(column) {
13039                push_qualified_join_column(qualifier, column, left, right, out);
13040            } else {
13041                push_unqualified_join_column(column, left, right, out);
13042            }
13043        }
13044        Projection::Field(FieldRef::TableColumn { table, column }, _) => {
13045            if table.is_empty() {
13046                push_unqualified_join_column(column, left, right, out);
13047            } else if let Some(side) = [left, right]
13048                .into_iter()
13049                .flatten()
13050                .find(|side| table == side.table.as_str() || table == side.alias.as_str())
13051            {
13052                push_join_column(&side.table, column, out);
13053            }
13054        }
13055        Projection::Field(FieldRef::NodeProperty { alias, property }, _)
13056        | Projection::Field(FieldRef::EdgeProperty { alias, property }, _) => {
13057            push_qualified_join_column(alias, property, left, right, out);
13058        }
13059        Projection::Function(_, args) => {
13060            for arg in args {
13061                collect_projection_columns_for_join_side(arg, left, right, out)?;
13062            }
13063        }
13064        Projection::Expression(_, _) | Projection::All | Projection::Field(_, _) => {}
13065        Projection::Window { args, .. } => {
13066            for arg in args {
13067                collect_projection_columns_for_join_side(arg, left, right, out)?;
13068            }
13069        }
13070    }
13071    Ok(())
13072}
13073
13074fn split_qualified_column(column: &str) -> Option<(&str, &str)> {
13075    let (qualifier, column) = column.split_once('.')?;
13076    if qualifier.is_empty() || column.is_empty() || column.contains('.') {
13077        return None;
13078    }
13079    Some((qualifier, column))
13080}
13081
13082fn push_qualified_join_column(
13083    qualifier: &str,
13084    column: &str,
13085    left: Option<&JoinTableSide>,
13086    right: Option<&JoinTableSide>,
13087    out: &mut HashMap<String, BTreeSet<String>>,
13088) {
13089    if let Some(side) = [left, right]
13090        .into_iter()
13091        .flatten()
13092        .find(|side| qualifier == side.table.as_str() || qualifier == side.alias.as_str())
13093    {
13094        push_join_column(&side.table, column, out);
13095    }
13096}
13097
13098fn push_unqualified_join_column(
13099    column: &str,
13100    left: Option<&JoinTableSide>,
13101    right: Option<&JoinTableSide>,
13102    out: &mut HashMap<String, BTreeSet<String>>,
13103) {
13104    for side in [left, right].into_iter().flatten() {
13105        push_join_column(&side.table, column, out);
13106    }
13107}
13108
13109fn push_join_column(table: &str, column: &str, out: &mut HashMap<String, BTreeSet<String>>) {
13110    if is_policy_column_name(column) {
13111        out.entry(table.to_string())
13112            .or_default()
13113            .insert(column.to_string());
13114    }
13115}
13116
13117fn push_policy_column(column: &str, out: &mut BTreeSet<String>) {
13118    if is_policy_column_name(column) {
13119        out.insert(column.to_string());
13120    }
13121}
13122
13123fn is_policy_column_name(column: &str) -> bool {
13124    !column.is_empty()
13125        && column != "*"
13126        && !column.starts_with("LIT:")
13127        && !column.starts_with("TYPE:")
13128}
13129
13130fn runtime_iam_context(
13131    role: crate::auth::Role,
13132    tenant: Option<&str>,
13133) -> crate::auth::policies::EvalContext {
13134    crate::auth::policies::EvalContext {
13135        principal_tenant: tenant.map(|t| t.to_string()),
13136        current_tenant: tenant.map(|t| t.to_string()),
13137        peer_ip: None,
13138        mfa_present: false,
13139        now_ms: crate::auth::now_ms(),
13140        principal_is_admin_role: role == crate::auth::Role::Admin,
13141        principal_is_platform_scoped: tenant.is_none(),
13142    }
13143}
13144
13145fn explicit_table_projection_columns(
13146    query: &crate::storage::query::ast::TableQuery,
13147) -> Vec<String> {
13148    use crate::storage::query::ast::{FieldRef, Projection};
13149
13150    let mut columns = Vec::new();
13151    for projection in crate::storage::query::sql_lowering::effective_table_projections(query) {
13152        match projection {
13153            Projection::Column(column) | Projection::Alias(column, _) => {
13154                push_unique(&mut columns, column)
13155            }
13156            Projection::Field(FieldRef::TableColumn { column, .. }, _) => {
13157                push_unique(&mut columns, column)
13158            }
13159            // SELECT * and expression/function projections need the
13160            // executor-wide column-policy context mapped in
13161            // docs/security/select-relational-column-policy-audit-2026-05-08.md.
13162            _ => {}
13163        }
13164    }
13165    columns
13166}
13167
13168fn explicit_graph_projection_properties(
13169    query: &crate::storage::query::ast::GraphQuery,
13170) -> Vec<String> {
13171    use crate::storage::query::ast::{FieldRef, Projection};
13172
13173    let mut columns = Vec::new();
13174    for projection in &query.return_ {
13175        match projection {
13176            Projection::Field(FieldRef::NodeProperty { property, .. }, _)
13177            | Projection::Field(FieldRef::EdgeProperty { property, .. }, _) => {
13178                push_unique(&mut columns, property.clone())
13179            }
13180            _ => {}
13181        }
13182    }
13183    columns
13184}
13185
13186fn push_unique(columns: &mut Vec<String>, column: String) {
13187    if !columns.iter().any(|existing| existing == &column) {
13188        columns.push(column);
13189    }
13190}
13191
13192fn principal_label(p: &crate::storage::query::ast::PolicyPrincipalRef) -> String {
13193    use crate::storage::query::ast::PolicyPrincipalRef;
13194    match p {
13195        PolicyPrincipalRef::User(u) => match &u.tenant {
13196            Some(t) => format!("user:{t}/{}", u.username),
13197            None => format!("user:{}", u.username),
13198        },
13199        PolicyPrincipalRef::Group(g) => format!("group:{g}"),
13200    }
13201}
13202
13203/// Render a `Decision` into the (decision, matched_policy_id, matched_sid)
13204/// shape used by every audit emit + the simulator response.
13205pub(crate) fn decision_to_strings(
13206    d: &crate::auth::policies::Decision,
13207) -> (String, Option<String>, Option<String>) {
13208    use crate::auth::policies::Decision;
13209    match d {
13210        Decision::Allow {
13211            matched_policy_id,
13212            matched_sid,
13213        } => (
13214            "allow".into(),
13215            Some(matched_policy_id.clone()),
13216            matched_sid.clone(),
13217        ),
13218        Decision::Deny {
13219            matched_policy_id,
13220            matched_sid,
13221        } => (
13222            "deny".into(),
13223            Some(matched_policy_id.clone()),
13224            matched_sid.clone(),
13225        ),
13226        Decision::DefaultDeny => ("default_deny".into(), None, None),
13227        Decision::AdminBypass => ("admin_bypass".into(), None, None),
13228    }
13229}
13230
13231fn relation_scopes_for_query(query: &QueryExpr) -> Vec<String> {
13232    let mut scopes = Vec::new();
13233    collect_relation_scopes(query, &mut scopes);
13234    scopes.sort();
13235    scopes.dedup();
13236    scopes
13237}
13238
13239fn collect_relation_scopes(query: &QueryExpr, scopes: &mut Vec<String>) {
13240    match query {
13241        QueryExpr::Table(table) => {
13242            if !table.table.is_empty() {
13243                scopes.push(table.table.clone());
13244            }
13245            if let Some(alias) = &table.alias {
13246                scopes.push(alias.clone());
13247            }
13248        }
13249        QueryExpr::Join(join) => {
13250            collect_relation_scopes(&join.left, scopes);
13251            collect_relation_scopes(&join.right, scopes);
13252        }
13253        _ => {}
13254    }
13255}
13256
13257fn query_references_outer_scope(query: &QueryExpr, outer_scopes: &[String]) -> bool {
13258    let inner_scopes = relation_scopes_for_query(query);
13259    query_expr_references_outer_scope(query, outer_scopes, &inner_scopes)
13260}
13261
13262fn query_expr_references_outer_scope(
13263    query: &QueryExpr,
13264    outer_scopes: &[String],
13265    inner_scopes: &[String],
13266) -> bool {
13267    match query {
13268        QueryExpr::Table(table) => {
13269            table.select_items.iter().any(|item| match item {
13270                crate::storage::query::ast::SelectItem::Wildcard => false,
13271                crate::storage::query::ast::SelectItem::Expr { expr, .. } => {
13272                    expr_references_outer_scope(expr, outer_scopes, inner_scopes)
13273                }
13274            }) || table
13275                .where_expr
13276                .as_ref()
13277                .is_some_and(|expr| expr_references_outer_scope(expr, outer_scopes, inner_scopes))
13278                || table.filter.as_ref().is_some_and(|filter| {
13279                    filter_references_outer_scope(filter, outer_scopes, inner_scopes)
13280                })
13281                || table.having_expr.as_ref().is_some_and(|expr| {
13282                    expr_references_outer_scope(expr, outer_scopes, inner_scopes)
13283                })
13284                || table.having.as_ref().is_some_and(|filter| {
13285                    filter_references_outer_scope(filter, outer_scopes, inner_scopes)
13286                })
13287                || table
13288                    .group_by_exprs
13289                    .iter()
13290                    .any(|expr| expr_references_outer_scope(expr, outer_scopes, inner_scopes))
13291                || table.order_by.iter().any(|clause| {
13292                    clause.expr.as_ref().is_some_and(|expr| {
13293                        expr_references_outer_scope(expr, outer_scopes, inner_scopes)
13294                    })
13295                })
13296        }
13297        QueryExpr::Join(join) => {
13298            query_expr_references_outer_scope(&join.left, outer_scopes, inner_scopes)
13299                || query_expr_references_outer_scope(&join.right, outer_scopes, inner_scopes)
13300                || join.filter.as_ref().is_some_and(|filter| {
13301                    filter_references_outer_scope(filter, outer_scopes, inner_scopes)
13302                })
13303                || join.return_items.iter().any(|item| match item {
13304                    crate::storage::query::ast::SelectItem::Wildcard => false,
13305                    crate::storage::query::ast::SelectItem::Expr { expr, .. } => {
13306                        expr_references_outer_scope(expr, outer_scopes, inner_scopes)
13307                    }
13308                })
13309        }
13310        _ => false,
13311    }
13312}
13313
13314fn filter_references_outer_scope(
13315    filter: &crate::storage::query::ast::Filter,
13316    outer_scopes: &[String],
13317    inner_scopes: &[String],
13318) -> bool {
13319    use crate::storage::query::ast::Filter;
13320    match filter {
13321        Filter::Compare { field, .. }
13322        | Filter::IsNull(field)
13323        | Filter::IsNotNull(field)
13324        | Filter::In { field, .. }
13325        | Filter::Between { field, .. }
13326        | Filter::Like { field, .. }
13327        | Filter::StartsWith { field, .. }
13328        | Filter::EndsWith { field, .. }
13329        | Filter::Contains { field, .. } => {
13330            field_ref_references_outer_scope(field, outer_scopes, inner_scopes)
13331        }
13332        Filter::CompareFields { left, right, .. } => {
13333            field_ref_references_outer_scope(left, outer_scopes, inner_scopes)
13334                || field_ref_references_outer_scope(right, outer_scopes, inner_scopes)
13335        }
13336        Filter::CompareExpr { lhs, rhs, .. } => {
13337            expr_references_outer_scope(lhs, outer_scopes, inner_scopes)
13338                || expr_references_outer_scope(rhs, outer_scopes, inner_scopes)
13339        }
13340        Filter::And(left, right) | Filter::Or(left, right) => {
13341            filter_references_outer_scope(left, outer_scopes, inner_scopes)
13342                || filter_references_outer_scope(right, outer_scopes, inner_scopes)
13343        }
13344        Filter::Not(inner) => filter_references_outer_scope(inner, outer_scopes, inner_scopes),
13345    }
13346}
13347
13348fn expr_references_outer_scope(
13349    expr: &crate::storage::query::ast::Expr,
13350    outer_scopes: &[String],
13351    inner_scopes: &[String],
13352) -> bool {
13353    use crate::storage::query::ast::Expr;
13354    match expr {
13355        Expr::Column { field, .. } => {
13356            field_ref_references_outer_scope(field, outer_scopes, inner_scopes)
13357        }
13358        Expr::BinaryOp { lhs, rhs, .. } => {
13359            expr_references_outer_scope(lhs, outer_scopes, inner_scopes)
13360                || expr_references_outer_scope(rhs, outer_scopes, inner_scopes)
13361        }
13362        Expr::UnaryOp { operand, .. }
13363        | Expr::Cast { inner: operand, .. }
13364        | Expr::IsNull { operand, .. } => {
13365            expr_references_outer_scope(operand, outer_scopes, inner_scopes)
13366        }
13367        Expr::FunctionCall { args, .. } => args
13368            .iter()
13369            .any(|arg| expr_references_outer_scope(arg, outer_scopes, inner_scopes)),
13370        Expr::Case {
13371            branches, else_, ..
13372        } => {
13373            branches.iter().any(|(cond, value)| {
13374                expr_references_outer_scope(cond, outer_scopes, inner_scopes)
13375                    || expr_references_outer_scope(value, outer_scopes, inner_scopes)
13376            }) || else_
13377                .as_ref()
13378                .is_some_and(|expr| expr_references_outer_scope(expr, outer_scopes, inner_scopes))
13379        }
13380        Expr::InList { target, values, .. } => {
13381            expr_references_outer_scope(target, outer_scopes, inner_scopes)
13382                || values
13383                    .iter()
13384                    .any(|value| expr_references_outer_scope(value, outer_scopes, inner_scopes))
13385        }
13386        Expr::Between {
13387            target, low, high, ..
13388        } => {
13389            expr_references_outer_scope(target, outer_scopes, inner_scopes)
13390                || expr_references_outer_scope(low, outer_scopes, inner_scopes)
13391                || expr_references_outer_scope(high, outer_scopes, inner_scopes)
13392        }
13393        Expr::Subquery { query, .. } => query_references_outer_scope(&query.query, inner_scopes),
13394        Expr::Literal { .. } | Expr::Parameter { .. } => false,
13395        Expr::WindowFunctionCall { args, window, .. } => {
13396            args.iter()
13397                .any(|arg| expr_references_outer_scope(arg, outer_scopes, inner_scopes))
13398                || window
13399                    .partition_by
13400                    .iter()
13401                    .any(|e| expr_references_outer_scope(e, outer_scopes, inner_scopes))
13402                || window
13403                    .order_by
13404                    .iter()
13405                    .any(|o| expr_references_outer_scope(&o.expr, outer_scopes, inner_scopes))
13406        }
13407    }
13408}
13409
13410fn field_ref_references_outer_scope(
13411    field: &crate::storage::query::ast::FieldRef,
13412    outer_scopes: &[String],
13413    inner_scopes: &[String],
13414) -> bool {
13415    match field {
13416        crate::storage::query::ast::FieldRef::TableColumn { table, .. } if !table.is_empty() => {
13417            outer_scopes.iter().any(|scope| scope == table)
13418                && !inner_scopes.iter().any(|scope| scope == table)
13419        }
13420        _ => false,
13421    }
13422}
13423
13424fn first_column_values(
13425    result: crate::storage::query::unified::UnifiedResult,
13426) -> RedDBResult<Vec<Value>> {
13427    if result.columns.len() > 1 {
13428        return Err(RedDBError::Query(
13429            "expression subquery must return exactly one column".to_string(),
13430        ));
13431    }
13432    let fallback_column = result
13433        .records
13434        .first()
13435        .and_then(|record| record.column_names().into_iter().next())
13436        .map(|name| name.to_string());
13437    let column = result.columns.first().cloned().or(fallback_column);
13438    let Some(column) = column else {
13439        return Ok(Vec::new());
13440    };
13441    Ok(result
13442        .records
13443        .iter()
13444        .map(|record| record.get(column.as_str()).cloned().unwrap_or(Value::Null))
13445        .collect())
13446}
13447
13448fn parse_timestamp_to_ms(s: &str) -> Option<u128> {
13449    // Bare integer ms.
13450    if let Ok(n) = s.parse::<u128>() {
13451        return Some(n);
13452    }
13453    // Fallback: ISO-8601 like 2030-01-02 03:04:05 — accept the date
13454    // portion only (midnight UTC). Full RFC3339 parsing is a stretch
13455    // goal; the common case is `'2030-01-01'`.
13456    if let Some(date) = s.split_whitespace().next() {
13457        let parts: Vec<&str> = date.split('-').collect();
13458        if parts.len() == 3 {
13459            let (y, m, d) = (parts[0], parts[1], parts[2]);
13460            if let (Ok(y), Ok(m), Ok(d)) = (y.parse::<i64>(), m.parse::<u32>(), d.parse::<u32>()) {
13461                // Days since 1970-01-01 — simple Julian arithmetic
13462                // suitable for years 1970-2100. Good enough for test
13463                // fixtures; precise parsing lands when we wire chrono.
13464                let days_in = days_from_civil(y, m, d);
13465                return Some((days_in as u128) * 86_400_000u128);
13466            }
13467        }
13468    }
13469    None
13470}
13471
13472/// Days from Unix epoch using H. Hinnant's civil-from-days algorithm.
13473/// Robust for the entire Gregorian range; used by `parse_timestamp_to_ms`.
13474fn days_from_civil(y: i64, m: u32, d: u32) -> i64 {
13475    let y = if m <= 2 { y - 1 } else { y };
13476    let era = if y >= 0 { y } else { y - 399 } / 400;
13477    let yoe = (y - era * 400) as u64; // [0, 399]
13478    let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) as u64 + 2) / 5 + d as u64 - 1;
13479    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
13480    era * 146097 + doe as i64 - 719468
13481}
13482
13483fn walk_plan_node(
13484    node: &crate::storage::query::planner::CanonicalLogicalNode,
13485    depth: usize,
13486    out: &mut Vec<crate::storage::query::unified::UnifiedRecord>,
13487) {
13488    use std::sync::Arc;
13489    let mut rec = crate::storage::query::unified::UnifiedRecord::default();
13490    rec.set_arc(Arc::from("op"), Value::text(node.operator.clone()));
13491    rec.set_arc(
13492        Arc::from("source"),
13493        node.source.clone().map(Value::text).unwrap_or(Value::Null),
13494    );
13495    rec.set_arc(Arc::from("est_rows"), Value::Float(node.estimated_rows));
13496    rec.set_arc(Arc::from("est_cost"), Value::Float(node.operator_cost));
13497    rec.set_arc(Arc::from("depth"), Value::Integer(depth as i64));
13498    out.push(rec);
13499    for child in &node.children {
13500        walk_plan_node(child, depth + 1, out);
13501    }
13502}
13503
13504#[cfg(test)]
13505mod inline_graph_tvf_tests {
13506    use super::*;
13507
13508    fn scopes_for(sql: &str) -> HashSet<String> {
13509        let expr = crate::storage::query::parser::parse(sql)
13510            .expect("parse")
13511            .query;
13512        query_expr_result_cache_scopes(&expr)
13513    }
13514
13515    #[test]
13516    fn inline_tvf_cache_scopes_include_source_collections() {
13517        // The result-cache key for the inline form must derive from the
13518        // `nodes`/`edges` source collections so a write to either invalidates
13519        // the cached result (issue #799).
13520        let scopes = scopes_for(
13521            "SELECT * FROM components(nodes => (SELECT id FROM hosts), edges => (SELECT src, dst FROM links))",
13522        );
13523        assert!(scopes.contains("hosts"), "nodes source scoped: {scopes:?}");
13524        assert!(scopes.contains("links"), "edges source scoped: {scopes:?}");
13525    }
13526
13527    #[test]
13528    fn graph_collection_tvf_cache_scope_is_graph_argument() {
13529        // The graph-collection form still materializes the active graph, but
13530        // result-cache invalidation is scoped to the named graph argument so
13531        // INSERT INTO g NODE/EDGE invalidates cached TVF rows.
13532        let scopes = scopes_for("SELECT * FROM components(g)");
13533        assert!(scopes.contains("g"), "collection form scoped: {scopes:?}");
13534    }
13535
13536    #[test]
13537    fn abstract_degree_centrality_counts_undirected_endpoints() {
13538        let nodes = vec!["a".to_string(), "b".to_string(), "c".to_string()];
13539        let edges = vec![
13540            ("a".to_string(), "b".to_string(), 1.0_f32),
13541            ("b".to_string(), "c".to_string(), 1.0_f32),
13542        ];
13543        let degrees = abstract_degree_centrality(&nodes, &edges);
13544        assert_eq!(
13545            degrees,
13546            vec![
13547                ("a".to_string(), 1),
13548                ("b".to_string(), 2),
13549                ("c".to_string(), 1),
13550            ]
13551        );
13552    }
13553}