Skip to main content

qail_core/ast/cmd/
rls.rs

1//! RLS tenant-scope injection for Qail queries.
2//!
3//! Provides `with_rls()` — the "one call to rule them all" method that
4//! auto-injects tenant isolation at the AST level based on query action.
5//!
6//! # Architecture
7//!
8//! ```text
9//!  Qail::get("orders")
10//!    .with_rls(&ctx)              ← Phase 4: AST injection (primary)
11//!    → WHERE tenant_id = 'uuid'
12//!
13//!  acquire_with_rls(ctx)      ← Phase 2: DB session vars (backup)
14//!    → SET app.current_tenant_id = 'uuid'
15//!
16//!  CREATE POLICY ...          ← Phase 3: DB policies (safety net)
17//!    → ENABLE ROW LEVEL SECURITY
18//! ```
19//!
20//! # Example
21//! ```
22//! use qail_core::Qail;
23//! use qail_core::rls::RlsContext;
24//!
25//! // Application boundary: register AND seal the process `Initialized`.
26//! // (`with_rls` refuses to run until the process declares its mode.)
27//! qail_core::rls::init_scope_registries_from_tables(&[("orders", "tenant_id")], &[])
28//!     .expect("scope registries seal");
29//!
30//! let ctx = RlsContext::tenant("550e8400-e29b-41d4-a716-446655440000");
31//! let query = Qail::get("orders").with_rls(&ctx).expect("rls should apply");
32//! // Transpiles to: SELECT * FROM orders WHERE tenant_id = '550e8400-...'
33//! ```
34
35use crate::ast::{
36    Action, Cage, CageKind, Condition, ConflictAction, Expr, JoinKind, LogicalOp, MergeAction,
37    MergeMatchKind, MergeSource, Operator, Qail, Value,
38};
39use crate::error::{QailBuildError, QailBuildResult};
40use crate::rls::RlsContext;
41use crate::rls::owner::try_lookup_owner_column;
42use crate::rls::tenant::try_lookup_tenant_column;
43
44/// Registry lookups used by scoping. These are the ONLY lookup forms this
45/// module may use: a poisoned registry is `RlsRegistryUnavailable`, never
46/// `None` — `None` means "unregistered", which is the fail-open answer.
47fn tenant_column_for(table: &str) -> QailBuildResult<Option<String>> {
48    map_registry_lookup(table, try_lookup_tenant_column(table))
49}
50
51fn owner_column_for(table: &str) -> QailBuildResult<Option<String>> {
52    map_registry_lookup(table, try_lookup_owner_column(table))
53}
54
55/// `Err` from a registry becomes `RlsRegistryUnavailable`; `Ok(None)` stays
56/// "unregistered". Separated so the mapping itself is testable with a real
57/// `Err` without poisoning the process registries.
58fn map_registry_lookup(
59    table: &str,
60    lookup: Result<Option<String>, String>,
61) -> QailBuildResult<Option<String>> {
62    lookup.map_err(|reason| QailBuildError::RlsRegistryUnavailable {
63        table: table.to_string(),
64        reason,
65    })
66}
67
68fn normalize_ident(raw: &str) -> String {
69    let trimmed = raw.trim();
70    if trimmed.starts_with('$') {
71        return trimmed.to_string();
72    }
73
74    let segment = trimmed.rsplit('.').next().unwrap_or(trimmed).trim();
75    let unquoted = if segment.len() >= 2 {
76        let bytes = segment.as_bytes();
77        let first = bytes[0] as char;
78        let last = bytes[bytes.len() - 1] as char;
79        if (first == '"' && last == '"')
80            || (first == '`' && last == '`')
81            || (first == '[' && last == ']')
82        {
83            &segment[1..segment.len() - 1]
84        } else {
85            segment
86        }
87    } else {
88        segment
89    };
90    unquoted.to_ascii_lowercase()
91}
92
93fn split_table_reference(table_ref: &str) -> (&str, Option<&str>) {
94    let parts = table_ref.split_whitespace().collect::<Vec<_>>();
95    match parts.as_slice() {
96        [table, alias] => (table, Some(alias)),
97        [table, as_keyword, alias] if as_keyword.eq_ignore_ascii_case("as") => (table, Some(alias)),
98        _ => (table_ref.trim(), None),
99    }
100}
101
102fn expr_named_eq(expr: &Expr, name: &str) -> bool {
103    matches!(expr, Expr::Named(existing) if normalize_ident(existing) == normalize_ident(name))
104}
105
106fn is_tenant_column_condition(cond: &Condition, tenant_col: &str) -> bool {
107    expr_named_eq(&cond.left, tenant_col)
108}
109
110/// Split `qualifier.column` into normalized segments (quotes stripped,
111/// case-folded). `column` alone has one segment.
112fn column_ref_segments(raw: &str) -> Vec<String> {
113    raw.trim().split('.').map(normalize_ident).collect()
114}
115
116/// Whether two column references name the same column of the same relation.
117///
118/// A qualifier names a DIFFERENT relation only when it is one of the query's
119/// join qualifiers (`joined`); every other reference — unqualified, or
120/// qualified with the primary alias or a stray name — resolves to the
121/// primary relation. So `a.tenant_id` and `b.tenant_id` never match when
122/// `a`/`b` are joins, while a user-supplied `orders.tenant_id` spoof still
123/// gets replaced by the injected primary predicate.
124fn same_scoped_column(a: &str, b: &str, primary: &str, joined: &[String]) -> bool {
125    let primary = normalize_ident(primary);
126    // Canonicalize every join qualifier to its last relation segment too: a
127    // schema-qualified JOIN ("public.b") must classify `b.tenant_id` as the
128    // JOINED relation, not fall through to the primary — otherwise the
129    // primary injection de-dup deletes the joined predicate (fail-open for
130    // the joined relation on schema-qualified CROSS joins).
131    let joined: Vec<String> = joined.iter().map(|j| normalize_ident(j)).collect();
132    let joined = joined.as_slice();
133    let resolve = |raw: &str| -> Vec<String> {
134        let mut segs = column_ref_segments(raw);
135        // A schema-qualified reference (schema.relation.column) keys its
136        // relation by the relation segment alone — the schema prefix does
137        // not distinguish relations anywhere else in this resolver (both
138        // `primary` and `joined` are already last-segment normalized), so
139        // keeping it made `public.orders.tenant_id` unequal to the bare
140        // `tenant_id` it superseded and BOTH predicates survived de-dup
141        // (fails closed: silently zero rows).
142        if segs.len() > 2 {
143            segs = segs.split_off(segs.len() - 2);
144        }
145        match segs.len() {
146            1 => segs.insert(0, primary.clone()),
147            2 if !joined.contains(&segs[0]) => segs[0] = primary.clone(),
148            _ => {}
149        }
150        segs
151    };
152    resolve(a) == resolve(b)
153}
154
155fn condition_references_tenant_column(cond: &Condition, tenant_col: &str) -> bool {
156    is_tenant_column_condition(cond, tenant_col)
157        || matches!(&cond.value, Value::Column(col) if normalize_ident(col) == normalize_ident(tenant_col))
158}
159
160fn payload_is_positional(cage: &Cage) -> bool {
161    cage.conditions.iter().all(|cond| {
162        matches!(
163            &cond.left,
164            Expr::Named(name) if name.starts_with('$') && name[1..].chars().all(|c| c.is_ascii_digit())
165        )
166    })
167}
168
169fn make_named_condition(column: &str, value: Value) -> Condition {
170    Condition {
171        left: Expr::Named(column.to_string()),
172        op: Operator::Eq,
173        value,
174        is_array_unnest: false,
175    }
176}
177
178fn make_positional_condition(index: usize, value: Value) -> Condition {
179    Condition {
180        left: Expr::Named(format!("${}", index + 1)),
181        op: Operator::Eq,
182        value,
183        is_array_unnest: false,
184    }
185}
186
187fn expr_projects_all_columns(expr: &Expr) -> bool {
188    matches!(expr, Expr::Star)
189        || matches!(expr, Expr::Named(name) if name == "*" || name.trim().ends_with(".*"))
190}
191
192fn expr_projects_tenant_col(expr: &Expr, tenant_col: &str) -> bool {
193    match expr {
194        Expr::Named(name) => normalize_ident(name) == normalize_ident(tenant_col),
195        Expr::Aliased { alias, .. } => normalize_ident(alias) == normalize_ident(tenant_col),
196        Expr::JsonAccess {
197            alias: Some(alias), ..
198        }
199        | Expr::FunctionCall {
200            alias: Some(alias), ..
201        }
202        | Expr::Cast {
203            alias: Some(alias), ..
204        }
205        | Expr::Binary {
206            alias: Some(alias), ..
207        }
208        | Expr::Case {
209            alias: Some(alias), ..
210        }
211        | Expr::SpecialFunction {
212            alias: Some(alias), ..
213        }
214        | Expr::ArrayConstructor {
215            alias: Some(alias), ..
216        }
217        | Expr::RowConstructor {
218            alias: Some(alias), ..
219        }
220        | Expr::Subscript {
221            alias: Some(alias), ..
222        }
223        | Expr::Collate {
224            alias: Some(alias), ..
225        }
226        | Expr::FieldAccess {
227            alias: Some(alias), ..
228        }
229        | Expr::Subquery {
230            alias: Some(alias), ..
231        }
232        | Expr::Exists {
233            alias: Some(alias), ..
234        } => normalize_ident(alias) == normalize_ident(tenant_col),
235        _ => false,
236    }
237}
238
239fn query_projects_tenant_col(query: &Qail, tenant_col: &str) -> bool {
240    query.columns.is_empty()
241        || query.columns.iter().any(|expr| {
242            expr_projects_all_columns(expr) || expr_projects_tenant_col(expr, tenant_col)
243        })
244}
245
246fn query_can_append_tenant_projection(query: &Qail) -> bool {
247    query.set_ops.is_empty()
248        && query.having.is_empty()
249        && !query
250            .columns
251            .iter()
252            .any(|expr| matches!(expr, Expr::Aggregate { .. } | Expr::Window { .. }))
253}
254
255fn ensure_merge_query_source_projects_tenant(
256    mut query: Qail,
257    target_table: &str,
258    tenant_col: &str,
259) -> QailBuildResult<Qail> {
260    if query_projects_tenant_col(&query, tenant_col) {
261        return Ok(query);
262    }
263
264    if !query_can_append_tenant_projection(&query) {
265        return Err(QailBuildError::RlsMergeSourceTenantProjectionRequired {
266            table: target_table.to_string(),
267            tenant_column: tenant_col.to_string(),
268        });
269    }
270
271    query.columns.push(Expr::Named(tenant_col.to_string()));
272    Ok(query)
273}
274
275impl Qail {
276    /// Apply tenant-scope isolation based on the query action.
277    ///
278    /// - **GET/SET/DEL** → injects `WHERE tenant_col = $value`
279    /// - **ADD/Upsert** → auto-sets `tenant_col` in payload
280    /// - **Global context** → injects `tenant_col IS NULL` (or payload `tenant_col = NULL`)
281    /// - **Super admins** → no-op (bypasses isolation)
282    /// - **Unregistered tables** → no-op (not a tenant table)
283    /// - **DDL/etc** → no-op
284    ///
285    /// # ⚠️ Unregistered relations FAIL OPEN
286    ///
287    /// The unregistered case returns the query **unscoped**, which makes this
288    /// call site *look* protected while emitting SQL that is not. The registry
289    /// is populated by scanning `schema.qail` for `table` blocks carrying a
290    /// literal `tenant_id`, so a renamed table, a differently-named tenant
291    /// column, or **any view** falls through silently.
292    ///
293    /// Views are the sharp edge: they cannot carry RLS of their own, and unless
294    /// declared `security_invoker` Postgres evaluates their base tables with the
295    /// view OWNER's rights — bypassing those tables' policies as well. A view
296    /// read under `with_rls` has neither layer.
297    ///
298    /// Assert it where scoping is load-bearing, with
299    /// [`crate::rls::tenant::scoping_applies`].
300    ///
301    /// # Example
302    /// ```ignore
303    /// let ctx = RlsContext::tenant("tenant-uuid");
304    /// let query = Qail::get("orders").with_rls(&ctx)?;
305    /// ```
306    pub fn with_rls(self, ctx: &RlsContext) -> QailBuildResult<Self> {
307        if ctx.bypasses_rls() {
308            return Ok(self);
309        }
310
311        match crate::rls::scope_registry_state() {
312            crate::rls::ScopeRegistryState::Initialized => {}
313            // Declared: DB policies carry isolation, AST injects nothing.
314            crate::rls::ScopeRegistryState::PolicyOnly => return Ok(self),
315            // Undeclared: refusing beats the silent no-op this used to be.
316            crate::rls::ScopeRegistryState::Uninitialized => {
317                return Err(QailBuildError::RlsRegistryUninitialized { table: self.table });
318            }
319        }
320
321        let (base_table, _) = split_table_reference(&self.table);
322        let tenant_col = tenant_column_for(base_table)?;
323        let owner_col = owner_column_for(base_table)?;
324
325        // Fail closed: a registered table demands the scope it registered for.
326        // Returning the query untouched here would be the exact false-green
327        // the build audit exists to prevent — `.with_rls()` present, nothing
328        // injected.
329        Self::ensure_scope_present(
330            &self.table,
331            tenant_col.as_deref(),
332            owner_col.as_deref(),
333            ctx,
334        )?;
335
336        // Nested relations FIRST, unconditionally. An unregistered outer
337        // relation (a CTE alias, a wrapper view) can embed a registered
338        // table; deciding "nothing to do" before visiting it would let that
339        // inner table run unscoped.
340        let mut scoped = self.scope_nested_rls(ctx)?;
341        scoped = scoped.scope_joined_relations(ctx)?;
342
343        if let Some(tenant_col) = tenant_col {
344            scoped = scoped.scope_tenant_dimension(&tenant_col, ctx)?;
345            scoped = scoped.scope_conflict_update(&tenant_col, Self::tenant_scope_value(ctx))?;
346        }
347        if let Some(owner_col) = owner_col {
348            scoped = scoped.scope_owner_dimension(&owner_col, ctx)?;
349            scoped = scoped.scope_conflict_update(
350                &owner_col,
351                Some(Value::String(ctx.user_id().to_string())),
352            )?;
353        }
354        Ok(scoped)
355    }
356
357    fn ensure_scope_present(
358        table: &str,
359        tenant_col: Option<&str>,
360        owner_col: Option<&str>,
361        ctx: &RlsContext,
362    ) -> QailBuildResult<()> {
363        if let Some(col) = tenant_col
364            && !ctx.is_global()
365            && !ctx.has_tenant()
366        {
367            return Err(QailBuildError::RlsScopeMissing {
368                table: table.to_string(),
369                scope: "tenant",
370                column: col.to_string(),
371            });
372        }
373        if let Some(col) = owner_col
374            && !ctx.has_user()
375        {
376            return Err(QailBuildError::RlsScopeMissing {
377                table: table.to_string(),
378                scope: "user",
379                column: col.to_string(),
380            });
381        }
382        Ok(())
383    }
384
385    /// `Some(tenant)` for tenant contexts, `None` for global (rendered as
386    /// `IS NULL`).
387    fn tenant_scope_value(ctx: &RlsContext) -> Option<Value> {
388        if ctx.is_global() {
389            None
390        } else {
391            Some(Value::String(ctx.tenant_id.clone()))
392        }
393    }
394
395    fn scope_condition(col: &str, value: Option<Value>) -> Condition {
396        match value {
397            Some(value) => make_named_condition(col, value),
398            None => Condition {
399                left: Expr::Named(col.to_string()),
400                op: Operator::IsNull,
401                value: Value::Null,
402                is_array_unnest: false,
403            },
404        }
405    }
406
407    /// Scope every JOINed relation that is registered for tenant or owner
408    /// isolation. The predicate goes into the join's ON clause (INNER/LEFT/
409    /// LATERAL) or the WHERE cage (CROSS); RIGHT/FULL cannot isolate and are
410    /// refused. Fails closed like the primary relation.
411    fn scope_joined_relations(mut self, ctx: &RlsContext) -> QailBuildResult<Self> {
412        let mut extra_filters: Vec<Condition> = Vec::new();
413        for join in &mut self.joins {
414            let (base, alias) = split_table_reference(&join.table);
415            let tenant_col = tenant_column_for(base)?;
416            let owner_col = owner_column_for(base)?;
417            if tenant_col.is_none() && owner_col.is_none() {
418                continue;
419            }
420            Self::ensure_scope_present(
421                &join.table,
422                tenant_col.as_deref(),
423                owner_col.as_deref(),
424                ctx,
425            )?;
426            let qualifier = alias.unwrap_or(base);
427            let mut conditions = Vec::new();
428            if let Some(col) = tenant_col {
429                conditions.push(Self::scope_condition(
430                    &format!("{qualifier}.{col}"),
431                    Self::tenant_scope_value(ctx),
432                ));
433            }
434            if let Some(col) = owner_col {
435                conditions.push(Self::scope_condition(
436                    &format!("{qualifier}.{col}"),
437                    Some(Value::String(ctx.user_id().to_string())),
438                ));
439            }
440            match join.kind {
441                JoinKind::Inner | JoinKind::Left | JoinKind::Lateral => {
442                    join.on_true = false;
443                    join.on.get_or_insert_with(Vec::new).extend(conditions);
444                }
445                JoinKind::Cross => extra_filters.extend(conditions),
446                JoinKind::Right | JoinKind::Full => {
447                    return Err(QailBuildError::RlsJoinKindUnsupported {
448                        table: join.table.clone(),
449                        join_kind: format!("{:?}", join.kind),
450                    });
451                }
452            }
453        }
454        for condition in extra_filters {
455            self = self.scope_to_condition(condition);
456        }
457        Ok(self)
458    }
459
460    /// `ON CONFLICT DO UPDATE` is an UPDATE of an existing row: it must be
461    /// gated by the same scope as the insert payload, and must not be able
462    /// to move the row to another scope.
463    fn scope_conflict_update(mut self, col: &str, value: Option<Value>) -> QailBuildResult<Self> {
464        let condition_col = self.primary_tenant_condition_col(col);
465        let primary = self.primary_relation_qualifier();
466        let joined = self.join_qualifiers();
467        let table = self.table.clone();
468        let Some(on_conflict) = self.on_conflict.as_mut() else {
469            return Ok(self);
470        };
471        let ConflictAction::DoUpdate { assignments } = &on_conflict.action else {
472            return Ok(self);
473        };
474        if assignments
475            .iter()
476            .any(|(assigned, _)| normalize_ident(assigned) == normalize_ident(col))
477        {
478            return Err(QailBuildError::RlsTenantColumnMutationDenied {
479                table,
480                tenant_column: col.to_string(),
481            });
482        }
483        on_conflict.where_conditions.retain(|c| {
484            !matches!(&c.left, Expr::Named(existing)
485                if same_scoped_column(existing, &condition_col, &primary, &joined))
486        });
487        on_conflict
488            .where_conditions
489            .push(Self::scope_condition(&condition_col, value));
490        Ok(self)
491    }
492
493    fn scope_tenant_dimension(self, tenant_col: &str, ctx: &RlsContext) -> QailBuildResult<Self> {
494        let scoped = self;
495        if ctx.is_global() {
496            return match scoped.action {
497                Action::Get
498                | Action::Cnt
499                | Action::Del
500                | Action::Over
501                | Action::Gen
502                | Action::Export
503                | Action::Search
504                | Action::Scroll => {
505                    let condition_col = scoped.primary_tenant_condition_col(tenant_col);
506                    Ok(scoped.scope_to_global(&condition_col))
507                }
508                Action::Set => scoped.scope_update_global(tenant_col),
509                Action::Add | Action::Upsert | Action::Put => {
510                    scoped.scope_insert_global(tenant_col)
511                }
512                Action::Merge => scoped.scope_merge_global(tenant_col),
513                _ => Ok(scoped),
514            };
515        }
516
517        match scoped.action {
518            // Read / Update / Delete → inject WHERE filter
519            Action::Get
520            | Action::Cnt
521            | Action::Del
522            | Action::Over
523            | Action::Gen
524            | Action::Export
525            | Action::Search
526            | Action::Scroll => {
527                let condition_col = scoped.primary_tenant_condition_col(tenant_col);
528                Ok(scoped.scope_to_tenant(&condition_col, ctx))
529            }
530            Action::Set => scoped.scope_update_tenant(tenant_col, ctx),
531            // Insert / Upsert → auto-set tenant column in payload
532            Action::Add | Action::Upsert | Action::Put => {
533                scoped.scope_insert_tenant(tenant_col, ctx)
534            }
535            Action::Merge => scoped.scope_merge_tenant(tenant_col, ctx),
536            // DDL, transactions, etc. → no injection
537            _ => Ok(scoped),
538        }
539    }
540
541    /// Owner-scope injection: `owner_col = ctx.user_id`.
542    ///
543    /// Orthogonal to tenant scoping — a table registered for both gets both
544    /// predicates ANDed. Global contexts carry no user, so they are rejected
545    /// earlier by the fail-closed check in [`Qail::with_rls`].
546    fn scope_owner_dimension(self, owner_col: &str, ctx: &RlsContext) -> QailBuildResult<Self> {
547        let user_id = Value::String(ctx.user_id().to_string());
548        match self.action {
549            Action::Get
550            | Action::Cnt
551            | Action::Del
552            | Action::Over
553            | Action::Gen
554            | Action::Export
555            | Action::Search
556            | Action::Scroll => {
557                let condition_col = self.primary_tenant_condition_col(owner_col);
558                Ok(self.scope_to_value(&condition_col, user_id))
559            }
560            Action::Set => {
561                self.reject_tenant_payload_mutation(owner_col)?;
562                let condition_col = self.primary_tenant_condition_col(owner_col);
563                Ok(self.scope_to_value(&condition_col, user_id))
564            }
565            Action::Add | Action::Upsert | Action::Put => {
566                self.scope_insert_value(owner_col, user_id)
567            }
568            Action::Merge => Err(QailBuildError::RlsOwnerMergeUnsupported {
569                table: self.table,
570                owner_column: owner_col.to_string(),
571            }),
572            _ => Ok(self),
573        }
574    }
575
576    /// Declare that this query's tenant isolation is DELIBERATELY delegated to
577    /// the database's row-level-security policies (Phase 2 session variable +
578    /// Phase 3 `CREATE POLICY`) instead of AST injection.
579    ///
580    /// Use this for queries that are *intentionally cross-tenant by policy* —
581    /// e.g. a reseller storefront reading an operator's rows through a
582    /// contract-scope policy (`... OR tenant_id IN (SELECT principal_tenant_id
583    /// FROM tenant_contracts ...)`), or an insert whose payload `tenant_id`
584    /// must NOT be overwritten with the session tenant (settlement/payout
585    /// attribution). Calling [`Qail::with_rls`] on such a query would inject
586    /// `WHERE tenant_col = ctx.tenant` (hiding the policy-granted rows) or
587    /// overwrite the payload tenant; leaving the query bare trips the
588    /// build-time RLS audit. This marker is the explicit, auditable middle
589    /// ground: the AST is left untouched and the audit treats the query as
590    /// consciously scoped.
591    ///
592    /// The `RlsContext` argument is not applied to the AST — it documents (and
593    /// type-checks) which session context the caller runs the query under.
594    /// Policy delegation only isolates when the connection was acquired with
595    /// an RLS context (`acquire_with_rls`) so `app.current_tenant_id` is set
596    /// for the policies to read; it is NOT a bypass.
597    ///
598    /// # Example
599    /// ```ignore
600    /// // Reseller storefront reads the operator's package via the
601    /// // charter contract-scope DB policy — do NOT inject
602    /// // WHERE tenant_id = <reseller>.
603    /// let ctx = tenant.to_rls_context();
604    /// let query = Qail::get("charter_packages")
605    ///     .eq("slug", slug)
606    ///     .with_rls_policy(&ctx);
607    /// ```
608    #[must_use]
609    pub fn with_rls_policy(self, _ctx: &RlsContext) -> Self {
610        self
611    }
612
613    fn scope_boxed_query_rls(query: &mut Box<Qail>, ctx: &RlsContext) -> QailBuildResult<()> {
614        let nested = std::mem::take(query.as_mut());
615        **query = nested.with_rls(ctx)?;
616        Ok(())
617    }
618
619    fn scope_nested_rls(mut self, ctx: &RlsContext) -> QailBuildResult<Self> {
620        for cte in &mut self.ctes {
621            Self::scope_boxed_query_rls(&mut cte.base_query, ctx)?;
622            if let Some(ref mut recursive_query) = cte.recursive_query {
623                Self::scope_boxed_query_rls(recursive_query, ctx)?;
624            }
625        }
626
627        if let Some(ref mut source_query) = self.source_query {
628            Self::scope_boxed_query_rls(source_query, ctx)?;
629        }
630
631        for (_, set_query) in &mut self.set_ops {
632            Self::scope_boxed_query_rls(set_query, ctx)?;
633        }
634
635        self.scope_embedded_expr_rls(ctx)?;
636
637        Ok(self)
638    }
639
640    fn scope_value_nested_rls(value: &mut Value, ctx: &RlsContext) -> QailBuildResult<()> {
641        match value {
642            Value::Array(values) => {
643                for value in values {
644                    Self::scope_value_nested_rls(value, ctx)?;
645                }
646            }
647            Value::Subquery(query) => {
648                Self::scope_boxed_query_rls(query, ctx)?;
649            }
650            Value::Expr(expr) => Self::scope_expr_nested_rls(expr, ctx)?,
651            _ => {}
652        }
653
654        Ok(())
655    }
656
657    fn scope_condition_nested_rls(
658        condition: &mut Condition,
659        ctx: &RlsContext,
660    ) -> QailBuildResult<()> {
661        Self::scope_expr_nested_rls(&mut condition.left, ctx)?;
662        Self::scope_value_nested_rls(&mut condition.value, ctx)
663    }
664
665    fn scope_expr_nested_rls(expr: &mut Expr, ctx: &RlsContext) -> QailBuildResult<()> {
666        match expr {
667            Expr::Aggregate {
668                filter: Some(filter),
669                ..
670            } => {
671                for condition in filter {
672                    Self::scope_condition_nested_rls(condition, ctx)?;
673                }
674            }
675            Expr::Cast { expr, .. } | Expr::Mod { col: expr, .. } | Expr::Collate { expr, .. } => {
676                Self::scope_expr_nested_rls(expr, ctx)?;
677            }
678            Expr::Window { params, order, .. } => {
679                for expr in params {
680                    Self::scope_expr_nested_rls(expr, ctx)?;
681                }
682                for cage in order {
683                    for condition in &mut cage.conditions {
684                        Self::scope_condition_nested_rls(condition, ctx)?;
685                    }
686                }
687            }
688            Expr::Case {
689                when_clauses,
690                else_value,
691                ..
692            } => {
693                for (condition, then_expr) in when_clauses {
694                    Self::scope_condition_nested_rls(condition, ctx)?;
695                    Self::scope_expr_nested_rls(then_expr, ctx)?;
696                }
697                if let Some(expr) = else_value {
698                    Self::scope_expr_nested_rls(expr, ctx)?;
699                }
700            }
701            Expr::FunctionCall { args, .. } => {
702                for expr in args {
703                    Self::scope_expr_nested_rls(expr, ctx)?;
704                }
705            }
706            Expr::SpecialFunction { args, .. } => {
707                for (_, expr) in args {
708                    Self::scope_expr_nested_rls(expr, ctx)?;
709                }
710            }
711            Expr::Binary { left, right, .. } => {
712                Self::scope_expr_nested_rls(left, ctx)?;
713                Self::scope_expr_nested_rls(right, ctx)?;
714            }
715            Expr::Literal(value) => Self::scope_value_nested_rls(value, ctx)?,
716            Expr::ArrayConstructor { elements, .. } | Expr::RowConstructor { elements, .. } => {
717                for expr in elements {
718                    Self::scope_expr_nested_rls(expr, ctx)?;
719                }
720            }
721            Expr::Subscript { expr, index, .. } => {
722                Self::scope_expr_nested_rls(expr, ctx)?;
723                Self::scope_expr_nested_rls(index, ctx)?;
724            }
725            Expr::FieldAccess { expr, .. } => Self::scope_expr_nested_rls(expr, ctx)?,
726            Expr::Subquery { query, .. } | Expr::Exists { query, .. } => {
727                Self::scope_boxed_query_rls(query, ctx)?;
728            }
729            Expr::Star
730            | Expr::Named(_)
731            | Expr::Aliased { .. }
732            | Expr::Aggregate { filter: None, .. }
733            | Expr::Def { .. }
734            | Expr::JsonAccess { .. } => {}
735        }
736
737        Ok(())
738    }
739
740    fn scope_embedded_expr_rls(&mut self, ctx: &RlsContext) -> QailBuildResult<()> {
741        for expr in &mut self.columns {
742            Self::scope_expr_nested_rls(expr, ctx)?;
743        }
744        for expr in &mut self.distinct_on {
745            Self::scope_expr_nested_rls(expr, ctx)?;
746        }
747        if let Some(returning) = &mut self.returning {
748            for expr in returning {
749                Self::scope_expr_nested_rls(expr, ctx)?;
750            }
751        }
752        for cage in &mut self.cages {
753            for condition in &mut cage.conditions {
754                Self::scope_condition_nested_rls(condition, ctx)?;
755            }
756        }
757        for condition in &mut self.having {
758            Self::scope_condition_nested_rls(condition, ctx)?;
759        }
760        for join in &mut self.joins {
761            if let Some(conditions) = &mut join.on {
762                for condition in conditions {
763                    Self::scope_condition_nested_rls(condition, ctx)?;
764                }
765            }
766        }
767        if let Some(on_conflict) = &mut self.on_conflict {
768            for condition in &mut on_conflict.where_conditions {
769                Self::scope_condition_nested_rls(condition, ctx)?;
770            }
771        }
772        if let Some(on_conflict) = &mut self.on_conflict
773            && let crate::ast::ConflictAction::DoUpdate { assignments } = &mut on_conflict.action
774        {
775            for (_, expr) in assignments {
776                Self::scope_expr_nested_rls(expr, ctx)?;
777            }
778        }
779        if let Some(merge) = &mut self.merge {
780            for condition in &mut merge.on {
781                Self::scope_condition_nested_rls(condition, ctx)?;
782            }
783            for clause in &mut merge.clauses {
784                for condition in &mut clause.condition {
785                    Self::scope_condition_nested_rls(condition, ctx)?;
786                }
787                match &mut clause.action {
788                    MergeAction::Update { assignments } => {
789                        for (_, expr) in assignments {
790                            Self::scope_expr_nested_rls(expr, ctx)?;
791                        }
792                    }
793                    MergeAction::Insert { values, .. } => {
794                        for expr in values {
795                            Self::scope_expr_nested_rls(expr, ctx)?;
796                        }
797                    }
798                    MergeAction::Delete | MergeAction::DoNothing => {}
799                }
800            }
801        }
802
803        Ok(())
804    }
805
806    fn scope_update_tenant(self, tenant_col: &str, ctx: &RlsContext) -> QailBuildResult<Self> {
807        self.reject_tenant_payload_mutation(tenant_col)?;
808        let condition_col = self.primary_tenant_condition_col(tenant_col);
809        Ok(self.scope_to_tenant(&condition_col, ctx))
810    }
811
812    fn scope_update_global(self, tenant_col: &str) -> QailBuildResult<Self> {
813        self.reject_tenant_payload_mutation(tenant_col)?;
814        let condition_col = self.primary_tenant_condition_col(tenant_col);
815        Ok(self.scope_to_global(&condition_col))
816    }
817
818    fn reject_tenant_payload_mutation(&self, tenant_col: &str) -> QailBuildResult<()> {
819        let assigns_tenant = self
820            .cages
821            .iter()
822            .filter(|cage| matches!(cage.kind, CageKind::Payload))
823            .flat_map(|cage| cage.conditions.iter())
824            .any(|cond| expr_named_eq(&cond.left, tenant_col));
825
826        if assigns_tenant {
827            return Err(QailBuildError::RlsTenantColumnMutationDenied {
828                table: self.table.clone(),
829                tenant_column: tenant_col.to_string(),
830            });
831        }
832
833        Ok(())
834    }
835
836    /// Inject a `WHERE tenant_col = scope_id` filter for reads.
837    ///
838    /// Adds the condition to the existing Filter cage (AND), or creates
839    /// a new one. Uses the same pattern as `.filter()`.
840    fn scope_to_tenant(self, tenant_col: &str, ctx: &RlsContext) -> Self {
841        self.scope_to_value(tenant_col, Value::String(ctx.tenant_id.clone()))
842    }
843
844    /// Inject a `WHERE col = value` filter, ANDed into the existing Filter cage.
845    fn scope_to_value(self, col: &str, value: Value) -> Self {
846        self.scope_to_condition(make_named_condition(col, value))
847    }
848
849    fn scope_to_condition(mut self, condition: Condition) -> Self {
850        let col = match &condition.left {
851            Expr::Named(name) => name.clone(),
852            _ => String::new(),
853        };
854        let primary = self.primary_relation_qualifier();
855        let joined = self.join_qualifiers();
856
857        // Try to append to existing filter cage. Replace only a predicate on
858        // the SAME relation's scope column — a `b.tenant_id` predicate from a
859        // CROSS-joined relation must survive an `a.tenant_id` injection.
860        let existing = self
861            .cages
862            .iter_mut()
863            .find(|c| matches!(c.kind, CageKind::Filter) && c.logical_op == LogicalOp::And);
864
865        if let Some(cage) = existing {
866            cage.conditions.retain(|cond| {
867                !matches!(&cond.left, Expr::Named(existing)
868                    if same_scoped_column(existing, &col, &primary, &joined))
869            });
870            cage.conditions.push(condition);
871        } else {
872            self.cages.push(Cage {
873                kind: CageKind::Filter,
874                conditions: vec![condition],
875                logical_op: LogicalOp::And,
876            });
877        }
878
879        self
880    }
881
882    /// Alias if the primary relation has one, else its base name.
883    fn primary_relation_qualifier(&self) -> String {
884        let (base, alias) = split_table_reference(&self.table);
885        alias.unwrap_or(base).to_string()
886    }
887
888    /// Normalized qualifier (alias or base name) of every JOINed relation.
889    fn join_qualifiers(&self) -> Vec<String> {
890        self.joins
891            .iter()
892            .map(|join| {
893                let (base, alias) = split_table_reference(&join.table);
894                normalize_ident(alias.unwrap_or(base))
895            })
896            .collect()
897    }
898
899    fn primary_tenant_condition_col(&self, tenant_col: &str) -> String {
900        // ALWAYS qualify with the alias (if any) or the base table name.
901        // A bare `tenant_col` is ambiguous the moment the query joins any
902        // other table that carries the same column — Postgres rejects the
903        // whole statement with 42702 ("column reference is ambiguous"),
904        // which took down every joined read/update under `with_rls` the
905        // first time the scope registries went live in production
906        // (articles, app_chat, admin order lists, 2026-08-24). Qualifying
907        // unconditionally is always-valid SQL, including UPDATE/DELETE
908        // WHERE clauses.
909        let (base, alias) = split_table_reference(&self.table);
910        let qualifier = alias.unwrap_or(base);
911        if qualifier.is_empty() {
912            tenant_col.to_string()
913        } else {
914            format!("{qualifier}.{tenant_col}")
915        }
916    }
917
918    /// Inject a `WHERE tenant_col IS NULL` filter for global/platform reads.
919    /// Same qualifier-aware de-duplication as the tenant path.
920    fn scope_to_global(self, tenant_col: &str) -> Self {
921        self.scope_to_condition(Self::scope_condition(tenant_col, None))
922    }
923
924    /// Auto-set tenant scope in INSERT/UPSERT payload.
925    ///
926    /// Adds the tenant column to the Payload cage so the scope id
927    /// is always included in INSERT statements.
928    fn scope_insert_tenant(self, tenant_col: &str, ctx: &RlsContext) -> QailBuildResult<Self> {
929        self.scope_insert_value(tenant_col, Value::String(ctx.tenant_id.clone()))
930    }
931
932    /// Auto-set `tenant_col = NULL` in INSERT/UPSERT payload for global rows.
933    fn scope_insert_global(self, tenant_col: &str) -> QailBuildResult<Self> {
934        self.scope_insert_value(tenant_col, Value::Null)
935    }
936
937    fn scope_insert_value(
938        mut self,
939        tenant_col: &str,
940        tenant_value: Value,
941    ) -> QailBuildResult<Self> {
942        let payload_idx = self
943            .cages
944            .iter()
945            .position(|c| matches!(c.kind, CageKind::Payload));
946
947        let Some(idx) = payload_idx else {
948            self.cages.push(Cage {
949                kind: CageKind::Payload,
950                conditions: vec![make_named_condition(tenant_col, tenant_value)],
951                logical_op: LogicalOp::And,
952            });
953            return Ok(self);
954        };
955
956        let positional = payload_is_positional(&self.cages[idx]);
957        if positional {
958            if self.columns.is_empty() {
959                return Err(QailBuildError::RlsInsertRequiresExplicitColumns {
960                    table: self.table,
961                    tenant_column: tenant_col.to_string(),
962                });
963            }
964
965            if let Some(col_idx) = self
966                .columns
967                .iter()
968                .position(|expr| expr_named_eq(expr, tenant_col))
969            {
970                let placeholder = format!("${}", col_idx + 1);
971                let cage = &mut self.cages[idx];
972                if let Some(cond) = cage
973                    .conditions
974                    .iter_mut()
975                    .find(|cond| expr_named_eq(&cond.left, &placeholder))
976                {
977                    cond.value = tenant_value;
978                    cond.op = Operator::Eq;
979                    cond.is_array_unnest = false;
980                } else {
981                    cage.conditions
982                        .push(make_positional_condition(col_idx, tenant_value));
983                }
984                return Ok(self);
985            }
986
987            if !self.columns.is_empty() {
988                self.columns.push(Expr::Named(tenant_col.to_string()));
989                let idx_col = self.columns.len() - 1;
990                let cage = &mut self.cages[idx];
991                cage.conditions
992                    .push(make_positional_condition(idx_col, tenant_value));
993                return Ok(self);
994            }
995        }
996
997        let cage = &mut self.cages[idx];
998        cage.conditions
999            .retain(|cond| !is_tenant_column_condition(cond, tenant_col));
1000        cage.conditions
1001            .push(make_named_condition(tenant_col, tenant_value));
1002        Ok(self)
1003    }
1004
1005    fn scope_merge_tenant(mut self, tenant_col: &str, ctx: &RlsContext) -> QailBuildResult<Self> {
1006        self.scope_merge_query_source(ctx, tenant_col)?;
1007        self.reject_merge_tenant_update_mutation(tenant_col)?;
1008        let target_col = self.merge_target_tenant_col(tenant_col);
1009        let source_col = self.merge_source_tenant_col(tenant_col)?;
1010        self.scope_merge_on_tenant_equality(tenant_col, target_col.clone(), source_col.clone());
1011
1012        let condition = Condition {
1013            left: Expr::Named(target_col),
1014            op: Operator::Eq,
1015            value: Value::String(ctx.tenant_id.clone()),
1016            is_array_unnest: false,
1017        };
1018        let source_condition = source_col.map(|source_col| Condition {
1019            left: Expr::Named(source_col),
1020            op: Operator::Eq,
1021            value: Value::String(ctx.tenant_id.clone()),
1022            is_array_unnest: false,
1023        });
1024        self.scope_merge_clause_conditions(tenant_col, condition, source_condition);
1025        self.scope_merge_insert_value(
1026            tenant_col,
1027            Expr::Literal(Value::String(ctx.tenant_id.clone())),
1028        )?;
1029        Ok(self)
1030    }
1031
1032    fn scope_merge_global(mut self, tenant_col: &str) -> QailBuildResult<Self> {
1033        self.scope_merge_query_source(&RlsContext::global(), tenant_col)?;
1034        self.reject_merge_tenant_update_mutation(tenant_col)?;
1035        let target_col = self.merge_target_tenant_col(tenant_col);
1036        let source_col = self.merge_source_tenant_col(tenant_col)?;
1037        self.scope_merge_on_tenant_equality(tenant_col, target_col.clone(), source_col.clone());
1038
1039        let condition = Condition {
1040            left: Expr::Named(target_col),
1041            op: Operator::IsNull,
1042            value: Value::Null,
1043            is_array_unnest: false,
1044        };
1045        let source_condition = source_col.map(|source_col| Condition {
1046            left: Expr::Named(source_col),
1047            op: Operator::IsNull,
1048            value: Value::Null,
1049            is_array_unnest: false,
1050        });
1051        self.scope_merge_clause_conditions(tenant_col, condition, source_condition);
1052        self.scope_merge_insert_value(tenant_col, Expr::Literal(Value::Null))?;
1053        Ok(self)
1054    }
1055
1056    fn scope_merge_query_source(
1057        &mut self,
1058        ctx: &RlsContext,
1059        tenant_col: &str,
1060    ) -> QailBuildResult<()> {
1061        let has_query_source = matches!(
1062            self.merge.as_ref().map(|merge| &merge.source),
1063            Some(MergeSource::Query { .. })
1064        );
1065        let Some(source_tenant_col) = self.merge_query_source_tenant_col(tenant_col)? else {
1066            if has_query_source {
1067                return Err(QailBuildError::RlsMergeSourceTenantProjectionRequired {
1068                    table: self.table.clone(),
1069                    tenant_column: tenant_col.to_string(),
1070                });
1071            }
1072            return Ok(());
1073        };
1074        let target_table = self.table.clone();
1075
1076        let Some(merge) = &mut self.merge else {
1077            return Ok(());
1078        };
1079        let MergeSource::Query { query, .. } = &mut merge.source else {
1080            return Ok(());
1081        };
1082
1083        let scoped_query = std::mem::take(query.as_mut()).with_rls(ctx)?;
1084        let scoped_query = ensure_merge_query_source_projects_tenant(
1085            scoped_query,
1086            &target_table,
1087            &source_tenant_col,
1088        )?;
1089        **query = scoped_query;
1090        Ok(())
1091    }
1092
1093    fn merge_target_tenant_col(&self, tenant_col: &str) -> String {
1094        let (target_table, inline_alias) = split_table_reference(&self.table);
1095        let qualifier = self
1096            .merge
1097            .as_ref()
1098            .and_then(|merge| merge.target_alias.as_ref())
1099            .map(String::as_str)
1100            .or(inline_alias)
1101            .unwrap_or(target_table);
1102        format!("{qualifier}.{tenant_col}")
1103    }
1104
1105    fn merge_source_tenant_col(&self, tenant_col: &str) -> QailBuildResult<Option<String>> {
1106        let Some(merge) = self.merge.as_ref() else {
1107            return Ok(None);
1108        };
1109        match &merge.source {
1110            MergeSource::Table { name, alias } => {
1111                let (source_table, inline_alias) = split_table_reference(name);
1112                let Some(source_tenant_col) = tenant_column_for(source_table)? else {
1113                    return Ok(None);
1114                };
1115                let qualifier = alias.as_deref().or(inline_alias).unwrap_or(source_table);
1116                Ok(Some(format!("{qualifier}.{source_tenant_col}")))
1117            }
1118            MergeSource::Query { query, alias } => {
1119                let Some(source_tenant_col) = self.merge_query_source_tenant_col(tenant_col)?
1120                else {
1121                    return Ok(None);
1122                };
1123                let Some(qualifier) = alias.as_deref() else {
1124                    return Ok(None);
1125                };
1126                if query_projects_tenant_col(query, &source_tenant_col) {
1127                    Ok(Some(format!("{qualifier}.{source_tenant_col}")))
1128                } else {
1129                    Ok(None)
1130                }
1131            }
1132        }
1133    }
1134
1135    fn merge_query_source_tenant_col(&self, tenant_col: &str) -> QailBuildResult<Option<String>> {
1136        let Some(merge) = self.merge.as_ref() else {
1137            return Ok(None);
1138        };
1139        let MergeSource::Query { query, .. } = &merge.source else {
1140            return Ok(None);
1141        };
1142
1143        let (source_table, _) = split_table_reference(&query.table);
1144        if let Some(source_tenant_col) = tenant_column_for(source_table)? {
1145            return Ok(Some(source_tenant_col));
1146        }
1147
1148        if query_projects_tenant_col(query, tenant_col)
1149            || self.cte_exposes_tenant_col(source_table, tenant_col)?
1150        {
1151            return Ok(Some(tenant_col.to_string()));
1152        }
1153
1154        Ok(None)
1155    }
1156
1157    fn cte_exposes_tenant_col(&self, cte_name: &str, tenant_col: &str) -> QailBuildResult<bool> {
1158        let Some(cte) = self
1159            .ctes
1160            .iter()
1161            .find(|cte| normalize_ident(&cte.name) == normalize_ident(cte_name))
1162        else {
1163            return Ok(false);
1164        };
1165        if !cte.columns.is_empty() {
1166            return Ok(cte
1167                .columns
1168                .iter()
1169                .any(|col| normalize_ident(col) == normalize_ident(tenant_col)));
1170        }
1171        let (base_table, _) = split_table_reference(&cte.base_query.table);
1172        if query_projects_tenant_col(&cte.base_query, tenant_col) {
1173            return Ok(true);
1174        }
1175        Ok(tenant_column_for(base_table)?
1176            .is_some_and(|col| normalize_ident(&col) == normalize_ident(tenant_col)))
1177    }
1178
1179    fn scope_merge_on_tenant_equality(
1180        &mut self,
1181        tenant_col: &str,
1182        target_col: String,
1183        source_col: Option<String>,
1184    ) {
1185        let Some(merge) = &mut self.merge else {
1186            return;
1187        };
1188        merge
1189            .on
1190            .retain(|cond| !condition_references_tenant_column(cond, tenant_col));
1191
1192        if let Some(source_col) = source_col {
1193            merge.on.push(Condition {
1194                left: Expr::Named(target_col),
1195                op: Operator::Eq,
1196                value: Value::Column(source_col),
1197                is_array_unnest: false,
1198            });
1199        }
1200    }
1201
1202    fn scope_merge_clause_conditions(
1203        &mut self,
1204        tenant_col: &str,
1205        target_condition: Condition,
1206        source_condition: Option<Condition>,
1207    ) {
1208        let Some(merge) = &mut self.merge else {
1209            return;
1210        };
1211
1212        for clause in &mut merge.clauses {
1213            clause
1214                .condition
1215                .retain(|cond| !condition_references_tenant_column(cond, tenant_col));
1216
1217            match clause.match_kind {
1218                MergeMatchKind::Matched | MergeMatchKind::NotMatchedBySource => {
1219                    clause.condition.push(target_condition.clone());
1220                }
1221                MergeMatchKind::NotMatchedByTarget => {
1222                    if let Some(condition) = &source_condition {
1223                        clause.condition.push(condition.clone());
1224                    }
1225                }
1226            }
1227        }
1228    }
1229
1230    fn scope_merge_insert_value(
1231        &mut self,
1232        tenant_col: &str,
1233        tenant_expr: Expr,
1234    ) -> QailBuildResult<()> {
1235        let Some(merge) = &mut self.merge else {
1236            return Ok(());
1237        };
1238
1239        for clause in &mut merge.clauses {
1240            let MergeAction::Insert { columns, values } = &mut clause.action else {
1241                continue;
1242            };
1243
1244            if columns.is_empty() {
1245                return Err(QailBuildError::RlsInsertRequiresExplicitColumns {
1246                    table: self.table.clone(),
1247                    tenant_column: tenant_col.to_string(),
1248                });
1249            }
1250
1251            if let Some(pos) = columns
1252                .iter()
1253                .position(|col| normalize_ident(col) == normalize_ident(tenant_col))
1254            {
1255                if let Some(value) = values.get_mut(pos) {
1256                    *value = tenant_expr.clone();
1257                } else {
1258                    values.push(tenant_expr.clone());
1259                }
1260            } else {
1261                columns.push(tenant_col.to_string());
1262                values.push(tenant_expr.clone());
1263            }
1264        }
1265
1266        Ok(())
1267    }
1268
1269    fn reject_merge_tenant_update_mutation(&self, tenant_col: &str) -> QailBuildResult<()> {
1270        let assigns_tenant = self
1271            .merge
1272            .as_ref()
1273            .is_some_and(|merge| {
1274                merge.clauses.iter().any(|clause| {
1275                    matches!(&clause.action, MergeAction::Update { assignments }
1276                        if assignments
1277                            .iter()
1278                            .any(|(column, _)| normalize_ident(column) == normalize_ident(tenant_col)))
1279                })
1280            });
1281
1282        if assigns_tenant {
1283            return Err(QailBuildError::RlsTenantColumnMutationDenied {
1284                table: self.table.clone(),
1285                tenant_column: tenant_col.to_string(),
1286            });
1287        }
1288
1289        Ok(())
1290    }
1291}
1292
1293#[cfg(test)]
1294mod tests {
1295    use super::*;
1296    use crate::ast::JoinKind;
1297    use crate::transpiler::ToSql;
1298
1299    // Each test uses a UNIQUE table name to avoid parallel-test interference
1300    // on the global registries.
1301    //
1302    // Registration goes through the application-boundary API so the process
1303    // is sealed `Initialized` the only way production can be: with at least
1304    // one table registered. The low-level `register_*` helpers are
1305    // mode-neutral and would leave `with_rls` at `RlsRegistryUninitialized`.
1306
1307    fn seal_tenant_table(table: &str, column: &str) {
1308        crate::rls::init_scope_registries_from_tables(&[(table, column)], &[])
1309            .expect("boundary registration");
1310    }
1311
1312    fn seal_owner_table(table: &str, column: &str) {
1313        crate::rls::init_scope_registries_from_tables(&[], &[(table, column)])
1314            .expect("boundary registration");
1315    }
1316
1317    /// `with_rls` on an unregistered table must be a no-op ONLY once the
1318    /// process is sealed `Initialized` by a real registration.
1319    fn ensure_initialized() {
1320        seal_tenant_table("_rls_tests_sentinel", "tenant_id");
1321    }
1322
1323    // ── Owner scope + fail-closed ────────────────────────────────────
1324
1325    #[test]
1326    fn owner_scope_injects_user_filter_on_get() {
1327        seal_owner_table("_rls_owner_listings", "seller_id");
1328        let ctx = RlsContext::user("u-1");
1329        let sql = Qail::get("_rls_owner_listings")
1330            .with_rls(&ctx)
1331            .expect("owner scope applies")
1332            .to_sql();
1333        assert!(sql.contains("seller_id = 'u-1'"), "{sql}");
1334    }
1335
1336    #[test]
1337    fn owner_scope_sets_payload_on_add_and_filters_set() {
1338        seal_owner_table("_rls_owner_posts", "author_id");
1339        let ctx = RlsContext::user("u-9");
1340        let add = Qail::add("_rls_owner_posts")
1341            .set_value("title", "hi")
1342            .with_rls(&ctx)
1343            .expect("add scoped")
1344            .to_sql();
1345        assert!(add.contains("'u-9'"), "{add}");
1346
1347        let set = Qail::set("_rls_owner_posts")
1348            .set_value("title", "edited")
1349            .with_rls(&ctx)
1350            .expect("set scoped")
1351            .to_sql();
1352        assert!(set.contains("author_id = 'u-9'"), "{set}");
1353
1354        let err = Qail::set("_rls_owner_posts")
1355            .set_value("author_id", "someone-else")
1356            .with_rls(&ctx)
1357            .expect_err("owner column mutation must be refused");
1358        assert!(matches!(
1359            err,
1360            QailBuildError::RlsTenantColumnMutationDenied { .. }
1361        ));
1362    }
1363
1364    #[test]
1365    fn tenant_and_owner_scopes_are_anded() {
1366        seal_tenant_table("_rls_both_notes", "tenant_id");
1367        seal_owner_table("_rls_both_notes", "user_id");
1368        let ctx = RlsContext::tenant("t-1").with_user("u-1");
1369        let sql = Qail::get("_rls_both_notes")
1370            .with_rls(&ctx)
1371            .expect("both scopes apply")
1372            .to_sql();
1373        assert!(sql.contains("tenant_id = 't-1'"), "{sql}");
1374        assert!(sql.contains("user_id = 'u-1'"), "{sql}");
1375        assert!(sql.contains(" AND "), "{sql}");
1376    }
1377
1378    #[test]
1379    fn registered_tenant_table_fails_closed_without_tenant() {
1380        seal_tenant_table("_rls_fc_orders", "tenant_id");
1381        let err = Qail::get("_rls_fc_orders")
1382            .with_rls(&RlsContext::user("u-1"))
1383            .expect_err("user-only context on a tenant table must not silently run unscoped");
1384        assert!(
1385            matches!(
1386                &err,
1387                QailBuildError::RlsScopeMissing {
1388                    scope: "tenant",
1389                    ..
1390                }
1391            ),
1392            "{err:?}"
1393        );
1394        let err = Qail::get("_rls_fc_orders")
1395            .with_rls(&RlsContext::empty())
1396            .expect_err("empty context must fail closed");
1397        assert!(matches!(err, QailBuildError::RlsScopeMissing { .. }));
1398    }
1399
1400    #[test]
1401    fn registry_lookup_failure_maps_to_registry_unavailable_not_unscoped() {
1402        // The process registries cannot be poisoned here without breaking
1403        // sibling tests, so feed the mapping layer a real registry Err (the
1404        // lock-level poison tests in rls::tests prove lookups produce one).
1405        assert!(
1406            matches!(super::tenant_column_for("_rls_probe_unreachable"), Ok(None)),
1407            "healthy registry → unregistered"
1408        );
1409
1410        let mapped =
1411            super::map_registry_lookup("orders", Err("tenant registry lock poisoned".to_string()))
1412                .expect_err("registry Err must never become Ok(None)");
1413        match mapped {
1414            QailBuildError::RlsRegistryUnavailable { table, reason } => {
1415                assert_eq!(table, "orders");
1416                assert!(reason.contains("poisoned"), "{reason}");
1417            }
1418            other => panic!("wrong variant: {other:?}"),
1419        }
1420        assert_eq!(
1421            super::map_registry_lookup("orders", Ok(Some("tenant_id".into()))).unwrap(),
1422            Some("tenant_id".to_string())
1423        );
1424    }
1425
1426    #[test]
1427    fn registered_owner_table_fails_closed_without_user() {
1428        seal_owner_table("_rls_fc_listings", "seller_id");
1429        let err = Qail::get("_rls_fc_listings")
1430            .with_rls(&RlsContext::tenant("t-1"))
1431            .expect_err("tenant-only context on an owner table must fail closed");
1432        assert!(
1433            matches!(&err, QailBuildError::RlsScopeMissing { scope: "user", .. }),
1434            "{err:?}"
1435        );
1436        assert!(
1437            Qail::get("_rls_fc_listings")
1438                .with_rls(&RlsContext::global())
1439                .is_err(),
1440            "global context carries no user"
1441        );
1442    }
1443
1444    #[test]
1445    fn owner_scope_rejects_merge_explicitly() {
1446        seal_owner_table("_rls_owner_merge", "owner_id");
1447        let ctx = RlsContext::user("u-1");
1448        let err = Qail::merge_into("_rls_owner_merge")
1449            .with_rls(&ctx)
1450            .expect_err("owner-scoped merge is unsupported, not silently unscoped");
1451        assert!(matches!(
1452            err,
1453            QailBuildError::RlsOwnerMergeUnsupported { .. }
1454        ));
1455    }
1456
1457    #[test]
1458    fn unregistered_table_with_user_only_context_stays_no_op() {
1459        ensure_initialized();
1460        let ctx = RlsContext::user("u-1");
1461        let sql = Qail::get("_rls_unregistered_reference")
1462            .with_rls(&ctx)
1463            .expect("no registry entry → untouched")
1464            .to_sql();
1465        assert!(!sql.contains("WHERE"), "{sql}");
1466    }
1467
1468    #[test]
1469    fn super_admin_bypasses_owner_scope() {
1470        seal_owner_table("_rls_owner_admin", "seller_id");
1471        let token = crate::rls::SuperAdminToken::for_system_process("owner_test");
1472        let sql = Qail::get("_rls_owner_admin")
1473            .with_rls(&RlsContext::super_admin(token))
1474            .expect("bypass")
1475            .to_sql();
1476        assert!(!sql.contains("seller_id"), "{sql}");
1477    }
1478
1479    // ── Nested / joined / upsert coverage ───────────────────────────
1480
1481    #[test]
1482    fn owner_table_inside_cte_is_scoped_even_when_outer_is_unregistered() {
1483        seal_owner_table("_rls_cte_inner_listings", "seller_id");
1484        let ctx = RlsContext::user("u-1");
1485        let inner = Qail::get("_rls_cte_inner_listings");
1486        let sql = Qail::get("mine")
1487            .with("mine", inner)
1488            .with_rls(&ctx)
1489            .expect("outer unregistered, inner owner table must still be scoped")
1490            .to_sql();
1491        assert!(sql.contains("seller_id = 'u-1'"), "{sql}");
1492    }
1493
1494    #[test]
1495    fn tenant_table_inside_cte_fails_closed_under_user_only_context() {
1496        seal_tenant_table("_rls_cte_inner_orders", "tenant_id");
1497        let inner = Qail::get("_rls_cte_inner_orders");
1498        let err = Qail::get("mine")
1499            .with("mine", inner)
1500            .with_rls(&RlsContext::user("u-1"))
1501            .expect_err("inner tenant table must not run unscoped");
1502        assert!(matches!(
1503            err,
1504            QailBuildError::RlsScopeMissing {
1505                scope: "tenant",
1506                ..
1507            }
1508        ));
1509    }
1510
1511    #[test]
1512    fn joined_owner_relation_gets_on_predicate() {
1513        seal_owner_table("_rls_join_threads", "owner_id");
1514        let ctx = RlsContext::user("u-7");
1515        let sql = Qail::get("_rls_join_msgs")
1516            .join(
1517                JoinKind::Inner,
1518                "_rls_join_threads t",
1519                "_rls_join_msgs.thread_id",
1520                "t.id",
1521            )
1522            .with_rls(&ctx)
1523            .expect("join scoped")
1524            .to_sql();
1525        assert!(sql.contains("t.owner_id = 'u-7'"), "{sql}");
1526    }
1527
1528    #[test]
1529    fn joined_tenant_relation_under_global_ctx_gets_is_null() {
1530        seal_tenant_table("_rls_join_refs", "tenant_id");
1531        let sql = Qail::get("_rls_join_main")
1532            .join(
1533                JoinKind::Left,
1534                "_rls_join_refs",
1535                "_rls_join_main.ref_id",
1536                "_rls_join_refs.id",
1537            )
1538            .with_rls(&RlsContext::global())
1539            .expect("join scoped")
1540            .to_sql();
1541        assert!(sql.contains("_rls_join_refs.tenant_id IS NULL"), "{sql}");
1542    }
1543
1544    #[test]
1545    fn joined_registered_relation_via_full_join_is_refused() {
1546        seal_tenant_table("_rls_join_full", "tenant_id");
1547        let err = Qail::get("_rls_join_main2")
1548            .join(
1549                JoinKind::Full,
1550                "_rls_join_full",
1551                "a.id",
1552                "_rls_join_full.id",
1553            )
1554            .with_rls(&RlsContext::tenant("t-1"))
1555            .expect_err("FULL join cannot isolate");
1556        assert!(matches!(err, QailBuildError::RlsJoinKindUnsupported { .. }));
1557    }
1558
1559    #[test]
1560    fn joined_registered_relation_fails_closed_without_scope() {
1561        seal_owner_table("_rls_join_owned", "owner_id");
1562        let err = Qail::get("_rls_join_main3")
1563            .join(
1564                JoinKind::Inner,
1565                "_rls_join_owned",
1566                "a.id",
1567                "_rls_join_owned.id",
1568            )
1569            .with_rls(&RlsContext::tenant("t-1"))
1570            .expect_err("joined owner table needs a user");
1571        assert!(matches!(
1572            err,
1573            QailBuildError::RlsScopeMissing { scope: "user", .. }
1574        ));
1575    }
1576
1577    #[test]
1578    fn multiple_cross_joined_registered_relations_keep_every_predicate() {
1579        seal_tenant_table("_rls_cross_a", "tenant_id");
1580        seal_tenant_table("_rls_cross_b", "tenant_id");
1581        seal_tenant_table("_rls_cross_main", "tenant_id");
1582        let ctx = RlsContext::tenant("t1");
1583        let mut q = Qail::get("_rls_cross_main");
1584        for t in ["_rls_cross_a a", "_rls_cross_b b"] {
1585            q.joins.push(crate::ast::Join {
1586                table: t.to_string(),
1587                kind: JoinKind::Cross,
1588                on: None,
1589                on_true: true,
1590            });
1591        }
1592        let sql = q.with_rls(&ctx).expect("scoped").to_sql();
1593        assert!(sql.contains("a.tenant_id = 't1'"), "{sql}");
1594        assert!(sql.contains("b.tenant_id = 't1'"), "{sql}");
1595        assert!(sql.contains("tenant_id = 't1'"), "{sql}");
1596        assert_eq!(sql.matches("tenant_id = 't1'").count(), 3, "{sql}");
1597    }
1598
1599    #[test]
1600    fn multiple_cross_joined_registered_relations_keep_every_predicate_under_global() {
1601        seal_tenant_table("_rls_gcross_a", "tenant_id");
1602        seal_tenant_table("_rls_gcross_b", "tenant_id");
1603        seal_tenant_table("_rls_gcross_main", "tenant_id");
1604        let mut q = Qail::get("_rls_gcross_main");
1605        for t in ["_rls_gcross_a a", "_rls_gcross_b b"] {
1606            q.joins.push(crate::ast::Join {
1607                table: t.to_string(),
1608                kind: JoinKind::Cross,
1609                on: None,
1610                on_true: true,
1611            });
1612        }
1613        let sql = q.with_rls(&RlsContext::global()).expect("scoped").to_sql();
1614        assert!(sql.contains("a.tenant_id IS NULL"), "{sql}");
1615        assert!(sql.contains("b.tenant_id IS NULL"), "{sql}");
1616        assert_eq!(sql.matches("tenant_id IS NULL").count(), 3, "{sql}");
1617    }
1618
1619    #[test]
1620    fn user_supplied_unqualified_scope_filter_is_replaced_not_duplicated() {
1621        seal_tenant_table("_rls_dedup_orders", "tenant_id");
1622        let sql = Qail::get("_rls_dedup_orders")
1623            .eq("tenant_id", "spoofed")
1624            .with_rls(&RlsContext::tenant("t1"))
1625            .expect("scoped")
1626            .to_sql();
1627        assert!(!sql.contains("spoofed"), "{sql}");
1628        assert_eq!(sql.matches("tenant_id = 't1'").count(), 1, "{sql}");
1629    }
1630
1631    #[test]
1632    fn on_conflict_where_subquery_on_registered_table_is_scoped() {
1633        seal_tenant_table("_rls_ocw_target", "tenant_id");
1634        seal_tenant_table("_rls_ocw_inner", "tenant_id");
1635        let ctx = RlsContext::tenant("t1");
1636        let mut q = Qail::add("_rls_ocw_target")
1637            .columns(["id"])
1638            .values(vec![Value::String("x".into())])
1639            .on_conflict_update(
1640                &["id"],
1641                &[("touched", Expr::Named("EXCLUDED.touched".into()))],
1642            );
1643        q.on_conflict
1644            .as_mut()
1645            .unwrap()
1646            .where_conditions
1647            .push(Condition {
1648                left: Expr::Named("id".into()),
1649                op: Operator::In,
1650                value: Value::Subquery(Box::new(Qail::get("_rls_ocw_inner").columns(["id"]))),
1651                is_array_unnest: false,
1652            });
1653        let sql = q.with_rls(&ctx).expect("scoped").to_sql();
1654        assert!(
1655            sql.contains("WHERE _rls_ocw_inner.tenant_id = 't1'"),
1656            "nested relation inside ON CONFLICT WHERE must be scoped: {sql}"
1657        );
1658    }
1659
1660    #[test]
1661    fn on_conflict_do_update_is_gated_by_owner_scope() {
1662        seal_owner_table("_rls_upsert_devices", "user_id");
1663        let ctx = RlsContext::user("u-3");
1664        let sql = Qail::add("_rls_upsert_devices")
1665            .columns(["token", "platform"])
1666            .values(vec![
1667                Value::String("tok".into()),
1668                Value::String("ios".into()),
1669            ])
1670            .on_conflict_update(
1671                &["token"],
1672                &[("platform", Expr::Named("EXCLUDED.platform".into()))],
1673            )
1674            .with_rls(&ctx)
1675            .expect("upsert scoped")
1676            .to_sql();
1677        assert!(sql.contains("DO UPDATE SET"), "{sql}");
1678        assert!(
1679            sql.contains("WHERE _rls_upsert_devices.user_id = 'u-3'"),
1680            "{sql}"
1681        );
1682        assert!(sql.contains("'u-3'"), "{sql}");
1683    }
1684
1685    #[test]
1686    fn on_conflict_do_update_cannot_reassign_scope_column() {
1687        seal_tenant_table("_rls_upsert_tenanted", "tenant_id");
1688        let ctx = RlsContext::tenant("t-1");
1689        let err = Qail::add("_rls_upsert_tenanted")
1690            .columns(["id"])
1691            .values(vec![Value::String("x".into())])
1692            .on_conflict_update(
1693                &["id"],
1694                &[("tenant_id", Expr::Named("EXCLUDED.tenant_id".into()))],
1695            )
1696            .with_rls(&ctx)
1697            .expect_err("conflict update must not move the row to another tenant");
1698        assert!(matches!(
1699            err,
1700            QailBuildError::RlsTenantColumnMutationDenied { .. }
1701        ));
1702    }
1703
1704    #[test]
1705    fn on_conflict_do_nothing_is_untouched() {
1706        seal_tenant_table("_rls_upsert_nothing", "tenant_id");
1707        let sql = Qail::add("_rls_upsert_nothing")
1708            .columns(["id"])
1709            .values(vec![Value::String("x".into())])
1710            .on_conflict_nothing(&["id"])
1711            .with_rls(&RlsContext::tenant("t-1"))
1712            .expect("ok")
1713            .to_sql();
1714        assert!(sql.contains("DO NOTHING"), "{sql}");
1715        assert!(!sql.contains("DO NOTHING WHERE"), "{sql}");
1716    }
1717
1718    #[test]
1719    fn test_with_rls_injects_filter_on_get() {
1720        seal_tenant_table("_rls_get_orders", "tenant_id");
1721
1722        let ctx = RlsContext::tenant("t-123");
1723        let query = Qail::get("_rls_get_orders")
1724            .with_rls(&ctx)
1725            .expect("rls should apply");
1726
1727        let filter = query
1728            .cages
1729            .iter()
1730            .find(|c| matches!(c.kind, CageKind::Filter));
1731        assert!(filter.is_some(), "Expected filter cage");
1732
1733        let conditions = &filter.unwrap().conditions;
1734        assert!(
1735            conditions.iter().any(|c| {
1736                matches!(&c.left, Expr::Named(n) if n.ends_with("tenant_id"))
1737                    && matches!(&c.value, Value::String(v) if v == "t-123")
1738            }),
1739            "Expected tenant_id = 't-123' condition"
1740        );
1741    }
1742
1743    #[test]
1744    fn test_with_rls_resolves_primary_table_alias_on_get() {
1745        seal_tenant_table("_rls_alias_get_orders", "tenant_id");
1746
1747        let ctx = RlsContext::tenant("tenant-alias");
1748        let query = Qail::get("_rls_alias_get_orders")
1749            .table_alias("o")
1750            .with_rls(&ctx)
1751            .expect("rls should apply through primary table alias");
1752
1753        let sql = query.to_sql();
1754        assert!(
1755            sql.contains("FROM _rls_alias_get_orders o"),
1756            "expected aliased FROM table: {sql}"
1757        );
1758        assert!(
1759            sql.contains("WHERE o.tenant_id = 'tenant-alias'"),
1760            "RLS tenant filter should use the primary alias: {sql}"
1761        );
1762    }
1763
1764    #[test]
1765    fn test_with_rls_injects_payload_on_add() {
1766        seal_tenant_table("_rls_add_orders", "tenant_id");
1767
1768        let ctx = RlsContext::tenant("t-456");
1769        let query = Qail::add("_rls_add_orders")
1770            .set_value("total", 100)
1771            .with_rls(&ctx)
1772            .expect("rls should apply");
1773
1774        let payload = query
1775            .cages
1776            .iter()
1777            .find(|c| matches!(c.kind, CageKind::Payload));
1778        assert!(payload.is_some(), "Expected payload cage");
1779
1780        let conditions = &payload.unwrap().conditions;
1781        assert!(
1782            conditions.iter().any(|c| {
1783                matches!(&c.left, Expr::Named(n) if n.ends_with("tenant_id"))
1784                    && matches!(&c.value, Value::String(v) if v == "t-456")
1785            }),
1786            "Expected tenant_id = 't-456' in payload"
1787        );
1788    }
1789
1790    #[test]
1791    fn test_with_rls_noop_for_super_admin() {
1792        seal_tenant_table("_rls_admin_orders", "tenant_id");
1793
1794        let token = crate::rls::SuperAdminToken::for_system_process("test_super_admin_noop");
1795        let ctx = RlsContext::super_admin(token);
1796        let query = Qail::get("_rls_admin_orders")
1797            .with_rls(&ctx)
1798            .expect("super admin rls should no-op");
1799
1800        let filter = query
1801            .cages
1802            .iter()
1803            .find(|c| matches!(c.kind, CageKind::Filter));
1804        assert!(filter.is_none(), "Super admin should not have filter");
1805    }
1806
1807    #[test]
1808    fn test_with_rls_noop_for_unregistered_table() {
1809        ensure_initialized();
1810        let ctx = RlsContext::tenant("t-789");
1811        let query = Qail::get("_rls_unreg_migrations")
1812            .with_rls(&ctx)
1813            .expect("unregistered table rls should no-op");
1814
1815        let filter = query
1816            .cages
1817            .iter()
1818            .find(|c| matches!(c.kind, CageKind::Filter));
1819        assert!(
1820            filter.is_none(),
1821            "Unregistered table should not have filter"
1822        );
1823    }
1824
1825    #[test]
1826    fn test_with_rls_noop_for_ddl() {
1827        seal_tenant_table("_rls_ddl_orders", "tenant_id");
1828
1829        let ctx = RlsContext::tenant("t-000");
1830        let query = Qail {
1831            action: Action::Make,
1832            table: "_rls_ddl_orders".to_string(),
1833            ..Default::default()
1834        };
1835        let query = query.with_rls(&ctx).expect("ddl rls should no-op");
1836
1837        assert!(query.cages.is_empty(), "DDL should not inject cages");
1838    }
1839
1840    #[test]
1841    fn test_with_rls_appends_to_existing_filter() {
1842        seal_tenant_table("_rls_merge_orders", "tenant_id");
1843
1844        let ctx = RlsContext::tenant("t-merge");
1845        let query = Qail::get("_rls_merge_orders")
1846            .filter("status", Operator::Eq, "active")
1847            .with_rls(&ctx)
1848            .expect("rls should apply");
1849
1850        let filters: Vec<_> = query
1851            .cages
1852            .iter()
1853            .filter(|c| matches!(c.kind, CageKind::Filter))
1854            .collect();
1855        assert_eq!(filters.len(), 1, "Should merge into one filter cage");
1856        assert_eq!(
1857            filters[0].conditions.len(),
1858            2,
1859            "Should have 2 conditions: status + tenant_id"
1860        );
1861    }
1862
1863    #[test]
1864    fn test_with_rls_does_not_merge_tenant_scope_into_or_filter_cage() {
1865        seal_tenant_table("_rls_or_orders", "tenant_id");
1866
1867        let ctx = RlsContext::tenant("t-or");
1868        let query = Qail::get("_rls_or_orders")
1869            .or_filter("status", Operator::Eq, "active")
1870            .or_filter("status", Operator::Eq, "pending")
1871            .with_rls(&ctx)
1872            .expect("rls should apply");
1873
1874        let or_filter = query
1875            .cages
1876            .iter()
1877            .find(|c| matches!(c.kind, CageKind::Filter) && c.logical_op == LogicalOp::Or)
1878            .expect("Expected OR filter cage");
1879        assert_eq!(
1880            or_filter.conditions.len(),
1881            2,
1882            "OR cage should keep only OR terms"
1883        );
1884        assert!(
1885            !or_filter
1886                .conditions
1887                .iter()
1888                .any(|c| is_tenant_column_condition(c, "tenant_id")),
1889            "tenant scope must not be injected into OR cage"
1890        );
1891
1892        let and_filter = query
1893            .cages
1894            .iter()
1895            .find(|c| matches!(c.kind, CageKind::Filter) && c.logical_op == LogicalOp::And)
1896            .expect("Expected AND filter cage for tenant scope");
1897        assert!(
1898            and_filter
1899                .conditions
1900                .iter()
1901                .any(|c| is_tenant_column_condition(c, "tenant_id")),
1902            "tenant scope must be enforced via AND cage"
1903        );
1904
1905        let sql = query.to_sql();
1906        assert!(
1907            sql.contains("tenant_id = 't-or'"),
1908            "Expected tenant scope in SQL: {sql}"
1909        );
1910        assert!(
1911            !sql.contains("OR tenant_id = 't-or'"),
1912            "tenant scope must not be OR-ed with user conditions: {sql}"
1913        );
1914    }
1915
1916    #[test]
1917    fn test_with_rls_on_set_injects_filter() {
1918        seal_tenant_table("_rls_set_orders", "tenant_id");
1919
1920        let ctx = RlsContext::tenant("t-set");
1921        let query = Qail::set("_rls_set_orders")
1922            .set_value("status", "shipped")
1923            .with_rls(&ctx)
1924            .expect("rls should apply");
1925
1926        let filter = query
1927            .cages
1928            .iter()
1929            .find(|c| matches!(c.kind, CageKind::Filter));
1930        assert!(filter.is_some(), "SET should inject filter");
1931
1932        let conditions = &filter.unwrap().conditions;
1933        assert!(
1934            conditions
1935                .iter()
1936                .any(|c| { matches!(&c.left, Expr::Named(n) if n.ends_with("tenant_id")) }),
1937            "Expected tenant_id filter on SET"
1938        );
1939    }
1940
1941    #[test]
1942    fn test_with_rls_resolves_primary_table_alias_on_set() {
1943        seal_tenant_table("_rls_alias_set_orders", "tenant_id");
1944
1945        let ctx = RlsContext::tenant("tenant-set-alias");
1946        let query = Qail::set("_rls_alias_set_orders")
1947            .table_alias("o")
1948            .set_value("status", "paid")
1949            .with_rls(&ctx)
1950            .expect("rls should apply through UPDATE alias");
1951
1952        let sql = query.to_sql();
1953        assert!(
1954            sql.contains("UPDATE _rls_alias_set_orders o SET status = 'paid'"),
1955            "expected aliased UPDATE target: {sql}"
1956        );
1957        assert!(
1958            sql.contains("WHERE o.tenant_id = 'tenant-set-alias'"),
1959            "RLS tenant filter should use the UPDATE alias: {sql}"
1960        );
1961    }
1962
1963    #[test]
1964    fn test_with_rls_on_set_rejects_tenant_column_update() {
1965        seal_tenant_table("_rls_set_tenant_rewrite_orders", "tenant_id");
1966
1967        let ctx = RlsContext::tenant("tenant-a");
1968        let err = Qail::set("_rls_set_tenant_rewrite_orders")
1969            .set_value("tenant_id", "tenant-b")
1970            .with_rls(&ctx)
1971            .expect_err("tenant column updates must fail closed");
1972
1973        assert!(err.to_string().contains("tenant column mutation"));
1974    }
1975
1976    #[test]
1977    fn test_with_rls_injects_filter_on_read_like_actions() {
1978        let actions = [
1979            (Action::Cnt, "_rls_cnt_orders"),
1980            (Action::Export, "_rls_export_orders"),
1981            (Action::Search, "_rls_search_vectors"),
1982            (Action::Scroll, "_rls_scroll_vectors"),
1983        ];
1984
1985        for (action, table) in actions {
1986            seal_tenant_table(table, "tenant_id");
1987
1988            let ctx = RlsContext::tenant("tenant-read-like");
1989            let query = Qail {
1990                action,
1991                table: table.to_string(),
1992                ..Default::default()
1993            }
1994            .with_rls(&ctx)
1995            .expect("read-like action should apply RLS");
1996
1997            let filter = query
1998                .cages
1999                .iter()
2000                .find(|c| matches!(c.kind, CageKind::Filter))
2001                .expect("Expected filter cage");
2002
2003            assert!(
2004                filter.conditions.iter().any(|c| {
2005                    matches!(&c.left, Expr::Named(n) if n.ends_with("tenant_id"))
2006                        && matches!(&c.value, Value::String(v) if v == "tenant-read-like")
2007                }),
2008                "Expected tenant filter on {action:?}"
2009            );
2010        }
2011    }
2012
2013    #[test]
2014    fn test_with_rls_empty_context_fails_closed_on_tenant_table() {
2015        seal_tenant_table("_rls_noops_orders", "tenant_id");
2016
2017        // A context without tenant_id: the table is registered for
2018        // tenant scope, so running it unscoped would be the silent false-green.
2019        let ctx = RlsContext::user("u-no-tenant");
2020        let err = Qail::get("_rls_noops_orders")
2021            .with_rls(&ctx)
2022            .expect_err("missing tenant on a registered table must fail closed");
2023        assert!(
2024            matches!(
2025                &err,
2026                QailBuildError::RlsScopeMissing {
2027                    scope: "tenant",
2028                    ..
2029                }
2030            ),
2031            "{err:?}"
2032        );
2033    }
2034
2035    #[test]
2036    fn test_with_rls_global_injects_is_null_filter() {
2037        seal_tenant_table("_rls_global_get_orders", "tenant_id");
2038
2039        let ctx = RlsContext::global();
2040        let query = Qail::get("_rls_global_get_orders")
2041            .with_rls(&ctx)
2042            .expect("global rls should apply");
2043
2044        let filter = query
2045            .cages
2046            .iter()
2047            .find(|c| matches!(c.kind, CageKind::Filter));
2048        assert!(filter.is_some(), "Expected filter cage for global scope");
2049
2050        let conditions = &filter.expect("filter cage").conditions;
2051        assert!(
2052            conditions.iter().any(|c| {
2053                matches!(&c.left, Expr::Named(n) if n.ends_with("tenant_id"))
2054                    && c.op == Operator::IsNull
2055                    && matches!(&c.value, Value::Null)
2056            }),
2057            "Expected tenant_id IS NULL condition"
2058        );
2059    }
2060
2061    #[test]
2062    fn test_with_rls_global_injects_null_payload_on_add() {
2063        seal_tenant_table("_rls_global_add_catalog", "tenant_id");
2064
2065        let ctx = RlsContext::global();
2066        let query = Qail::add("_rls_global_add_catalog")
2067            .set_value("name", "item")
2068            .with_rls(&ctx)
2069            .expect("global rls should apply");
2070
2071        let payload = query
2072            .cages
2073            .iter()
2074            .find(|c| matches!(c.kind, CageKind::Payload));
2075        assert!(payload.is_some(), "Expected payload cage");
2076
2077        let conditions = &payload.expect("payload cage").conditions;
2078        assert!(
2079            conditions.iter().any(|c| {
2080                matches!(&c.left, Expr::Named(n) if n.ends_with("tenant_id"))
2081                    && matches!(&c.value, Value::Null)
2082            }),
2083            "Expected tenant_id = NULL in payload"
2084        );
2085    }
2086
2087    #[test]
2088    fn test_with_rls_scopes_expression_subquery() {
2089        seal_tenant_table("_rls_expr_orders", "tenant_id");
2090        seal_tenant_table("_rls_expr_invoices", "tenant_id");
2091
2092        let ctx = RlsContext::tenant("tenant-expr");
2093        let mut query = Qail::get("_rls_expr_orders").columns(["id"]);
2094        query.columns.push(Expr::Subquery {
2095            query: Box::new(Qail::get("_rls_expr_invoices").columns(["total"])),
2096            alias: Some("invoice_total".to_string()),
2097        });
2098
2099        let query = query.with_rls(&ctx).expect("rls should apply");
2100        let subquery = query
2101            .columns
2102            .iter()
2103            .find_map(|expr| {
2104                if let Expr::Subquery { query, .. } = expr {
2105                    Some(query)
2106                } else {
2107                    None
2108                }
2109            })
2110            .expect("expression subquery");
2111
2112        assert!(subquery.cages.iter().any(|cage| {
2113            matches!(cage.kind, CageKind::Filter) && cage.conditions.iter().any(|condition| {
2114                matches!(&condition.left, Expr::Named(name) if name.ends_with("tenant_id"))
2115                    && matches!(&condition.value, Value::String(value) if value == "tenant-expr")
2116            })
2117        }));
2118    }
2119
2120    #[test]
2121    fn test_with_rls_scopes_condition_value_subquery() {
2122        seal_tenant_table("_rls_condition_orders", "tenant_id");
2123        seal_tenant_table("_rls_condition_invoices", "tenant_id");
2124
2125        let ctx = RlsContext::tenant("tenant-condition");
2126        let query = Qail::get("_rls_condition_orders")
2127            .filter(
2128                "id",
2129                Operator::In,
2130                Value::Subquery(Box::new(
2131                    Qail::get("_rls_condition_invoices").columns(["order_id"]),
2132                )),
2133            )
2134            .with_rls(&ctx)
2135            .expect("rls should apply");
2136
2137        let subquery = query
2138            .cages
2139            .iter()
2140            .flat_map(|cage| &cage.conditions)
2141            .find_map(|condition| {
2142                if let Value::Subquery(query) = &condition.value {
2143                    Some(query)
2144                } else {
2145                    None
2146                }
2147            })
2148            .expect("condition subquery");
2149
2150        assert!(subquery.cages.iter().any(|cage| {
2151            matches!(cage.kind, CageKind::Filter)
2152                && cage.conditions.iter().any(|condition| {
2153                    matches!(&condition.left, Expr::Named(name) if name.ends_with("tenant_id"))
2154                        && matches!(&condition.value, Value::String(value) if value == "tenant-condition")
2155                })
2156        }));
2157    }
2158
2159    #[test]
2160    fn test_with_rls_scopes_merge_on_and_insert_action() {
2161        seal_tenant_table("_rls_merge_upsert_orders", "tenant_id");
2162        seal_tenant_table("_rls_merge_source_orders", "tenant_id");
2163
2164        let ctx = RlsContext::tenant("tenant-merge");
2165        let query = Qail::merge_into("_rls_merge_upsert_orders")
2166            .target_alias("t")
2167            .using_table_as("_rls_merge_source_orders", "s")
2168            .merge_on_column("t.id", Operator::Eq, "s.id")
2169            .when_matched_update(&[("status", Expr::Named("s.status".to_string()))])
2170            .when_not_matched_insert(
2171                &["id", "status"],
2172                &[
2173                    Expr::Named("s.id".to_string()),
2174                    Expr::Named("s.status".to_string()),
2175                ],
2176            )
2177            .with_rls(&ctx)
2178            .expect("merge rls should apply");
2179
2180        let sql = query.to_sql();
2181        assert!(
2182            sql.contains("ON t.id = s.id AND t.tenant_id = s.tenant_id"),
2183            "MERGE ON must preserve target/source tenant equality: {sql}"
2184        );
2185        assert!(
2186            sql.contains("WHEN MATCHED AND t.tenant_id = 'tenant-merge' THEN UPDATE"),
2187            "MERGE matched branch must be target-tenant scoped: {sql}"
2188        );
2189        assert!(
2190            sql.contains("WHEN NOT MATCHED BY TARGET AND s.tenant_id = 'tenant-merge' THEN INSERT"),
2191            "MERGE insert branch must be source-tenant scoped: {sql}"
2192        );
2193        assert!(
2194            sql.contains("INSERT (id, status, tenant_id) VALUES (s.id, s.status, 'tenant-merge')"),
2195            "MERGE insert branch must include tenant value: {sql}"
2196        );
2197    }
2198
2199    #[test]
2200    fn test_with_rls_scopes_merge_inline_source_alias() {
2201        seal_tenant_table("_rls_merge_inline_target_orders", "tenant_id");
2202        seal_tenant_table("_rls_merge_inline_source_orders", "tenant_id");
2203
2204        let ctx = RlsContext::tenant("tenant-inline");
2205        let query = Qail::merge_into("_rls_merge_inline_target_orders")
2206            .target_alias("t")
2207            .using_table("_rls_merge_inline_source_orders s")
2208            .merge_on_column("t.id", Operator::Eq, "s.id")
2209            .when_matched_update(&[("status", Expr::Named("s.status".to_string()))])
2210            .when_not_matched_insert(
2211                &["id", "status"],
2212                &[
2213                    Expr::Named("s.id".to_string()),
2214                    Expr::Named("s.status".to_string()),
2215                ],
2216            )
2217            .with_rls(&ctx)
2218            .expect("merge rls should apply through inline source alias");
2219
2220        let sql = query.to_sql();
2221        assert!(
2222            sql.contains("USING _rls_merge_inline_source_orders s"),
2223            "MERGE source should keep inline alias: {sql}"
2224        );
2225        assert!(
2226            sql.contains("ON t.id = s.id AND t.tenant_id = s.tenant_id"),
2227            "MERGE ON must scope inline source alias tenant equality: {sql}"
2228        );
2229        assert!(
2230            sql.contains(
2231                "WHEN NOT MATCHED BY TARGET AND s.tenant_id = 'tenant-inline' THEN INSERT"
2232            ),
2233            "MERGE insert branch must scope inline source alias: {sql}"
2234        );
2235    }
2236
2237    #[test]
2238    fn test_with_rls_scopes_merge_query_source() {
2239        seal_tenant_table("_rls_merge_query_target_orders", "tenant_id");
2240        seal_tenant_table("_rls_merge_query_source_orders", "tenant_id");
2241
2242        let ctx = RlsContext::tenant("tenant-query");
2243        let source = Qail::get("_rls_merge_query_source_orders").columns(["id", "status"]);
2244        let query = Qail::merge_into("_rls_merge_query_target_orders")
2245            .target_alias("t")
2246            .using_query_as(source, "s")
2247            .merge_on_column("t.id", Operator::Eq, "s.id")
2248            .when_not_matched_insert(
2249                &["id", "status"],
2250                &[
2251                    Expr::Named("s.id".to_string()),
2252                    Expr::Named("s.status".to_string()),
2253                ],
2254            )
2255            .with_rls(&ctx)
2256            .expect("merge rls should apply");
2257
2258        let merge = query.merge.as_ref().expect("merge spec");
2259        let MergeSource::Query {
2260            query: source_query,
2261            ..
2262        } = &merge.source
2263        else {
2264            panic!("expected query source");
2265        };
2266        assert!(
2267            source_query.cages.iter().any(|cage| {
2268                matches!(cage.kind, CageKind::Filter)
2269                    && cage.conditions.iter().any(|condition| {
2270                        matches!(&condition.left, Expr::Named(name) if name.ends_with("tenant_id"))
2271                            && condition.op == Operator::Eq
2272                            && matches!(&condition.value, Value::String(value) if value == "tenant-query")
2273                    })
2274            }),
2275            "MERGE query source must be tenant-scoped"
2276        );
2277        assert!(
2278            source_query
2279                .columns
2280                .iter()
2281                .any(|expr| matches!(expr, Expr::Named(name) if name.ends_with("tenant_id"))),
2282            "MERGE query source must project tenant_id for ON classification"
2283        );
2284
2285        let sql = query.to_sql();
2286        assert!(
2287            sql.contains("ON t.id = s.id AND t.tenant_id = s.tenant_id"),
2288            "MERGE query source ON must include target/source tenant equality: {sql}"
2289        );
2290        assert!(
2291            sql.contains("WHEN NOT MATCHED BY TARGET AND s.tenant_id = 'tenant-query' THEN INSERT"),
2292            "MERGE query source insert branch must be source-tenant scoped: {sql}"
2293        );
2294    }
2295
2296    #[test]
2297    fn test_with_rls_scopes_aliased_merge_query_source_table() {
2298        seal_tenant_table("_rls_merge_query_alias_target_orders", "tenant_id");
2299        seal_tenant_table("_rls_merge_query_alias_source_orders", "tenant_id");
2300
2301        let ctx = RlsContext::tenant("tenant-query-alias");
2302        let source = Qail::get("_rls_merge_query_alias_source_orders")
2303            .table_alias("base")
2304            .columns(["id", "status"]);
2305        let query = Qail::merge_into("_rls_merge_query_alias_target_orders")
2306            .target_alias("t")
2307            .using_query_as(source, "s")
2308            .merge_on_column("t.id", Operator::Eq, "s.id")
2309            .when_matched_update(&[("status", Expr::Named("s.status".to_string()))])
2310            .when_not_matched_insert(
2311                &["id", "status"],
2312                &[
2313                    Expr::Named("s.id".to_string()),
2314                    Expr::Named("s.status".to_string()),
2315                ],
2316            )
2317            .with_rls(&ctx)
2318            .expect("merge rls should apply through aliased source query table");
2319
2320        let sql = query.to_sql();
2321        assert!(
2322            sql.contains("FROM _rls_merge_query_alias_source_orders base WHERE base.tenant_id = 'tenant-query-alias'"),
2323            "MERGE source query should be scoped through its base-table alias: {sql}"
2324        );
2325        assert!(
2326            sql.contains("ON t.id = s.id AND t.tenant_id = s.tenant_id"),
2327            "MERGE query source ON must include outer source tenant equality: {sql}"
2328        );
2329    }
2330
2331    #[test]
2332    fn test_with_rls_scopes_cte_backed_merge_source() {
2333        seal_tenant_table("_rls_merge_cte_target_orders", "tenant_id");
2334        seal_tenant_table("_rls_merge_cte_source_orders", "tenant_id");
2335
2336        let ctx = RlsContext::tenant("tenant-cte");
2337        let incoming =
2338            Qail::get("_rls_merge_cte_source_orders").columns(["id", "status", "tenant_id"]);
2339        let source_query = Qail::get("incoming").columns(["id", "status", "tenant_id"]);
2340        let query = Qail::merge_into("_rls_merge_cte_target_orders")
2341            .target_alias("t")
2342            .with("incoming", incoming)
2343            .using_query_as(source_query, "s")
2344            .merge_on_column("t.id", Operator::Eq, "s.id")
2345            .when_matched_update(&[("status", Expr::Named("s.status".to_string()))])
2346            .when_not_matched_insert(
2347                &["id", "status"],
2348                &[
2349                    Expr::Named("s.id".to_string()),
2350                    Expr::Named("s.status".to_string()),
2351                ],
2352            )
2353            .with_rls(&ctx)
2354            .expect("merge rls should apply");
2355
2356        let cte = query.ctes.first().expect("incoming CTE");
2357        assert!(
2358            cte.base_query.cages.iter().any(|cage| {
2359                matches!(cage.kind, CageKind::Filter) && cage.conditions.iter().any(|condition| {
2360                    matches!(&condition.left, Expr::Named(name) if name.ends_with("tenant_id"))
2361                        && condition.op == Operator::Eq
2362                        && matches!(&condition.value, Value::String(value) if value == "tenant-cte")
2363                })
2364            }),
2365            "outer MERGE CTE source must be tenant-scoped"
2366        );
2367
2368        let sql = query.to_sql();
2369        assert!(
2370            sql.contains("ON t.id = s.id AND t.tenant_id = s.tenant_id"),
2371            "CTE-backed MERGE query source ON must include tenant equality: {sql}"
2372        );
2373        assert!(
2374            sql.contains("WHEN NOT MATCHED BY TARGET AND s.tenant_id = 'tenant-cte' THEN INSERT"),
2375            "CTE-backed MERGE insert branch must be source-tenant scoped: {sql}"
2376        );
2377    }
2378
2379    #[test]
2380    fn test_with_rls_scopes_cte_alias_queries_before_table_lookup() {
2381        seal_tenant_table("_rls_cte_alias_source_orders", "tenant_id");
2382
2383        let ctx = RlsContext::tenant("tenant-alias");
2384        let query = Qail::get("incoming")
2385            .with(
2386                "incoming",
2387                Qail::get("_rls_cte_alias_source_orders").columns(["id", "tenant_id"]),
2388            )
2389            .with_rls(&ctx)
2390            .expect("cte alias query should still scope registered CTE body");
2391
2392        let cte = query.ctes.first().expect("incoming CTE");
2393        assert!(
2394            cte.base_query.cages.iter().any(|cage| {
2395                matches!(cage.kind, CageKind::Filter)
2396                    && cage.conditions.iter().any(|condition| {
2397                        matches!(&condition.left, Expr::Named(name) if name.ends_with("tenant_id"))
2398                            && matches!(&condition.value, Value::String(value) if value == "tenant-alias")
2399                    })
2400            }),
2401            "registered CTE bodies must be scoped even when outer table is a CTE alias"
2402        );
2403    }
2404
2405    #[test]
2406    fn test_with_rls_rejects_merge_tenant_column_update() {
2407        seal_tenant_table("_rls_merge_tenant_rewrite_orders", "tenant_id");
2408        seal_tenant_table("_rls_merge_tenant_rewrite_source", "tenant_id");
2409
2410        let ctx = RlsContext::tenant("tenant-a");
2411        let err = Qail::merge_into("_rls_merge_tenant_rewrite_orders")
2412            .using_table_as("_rls_merge_tenant_rewrite_source", "s")
2413            .merge_on_column("_rls_merge_tenant_rewrite_orders.id", Operator::Eq, "s.id")
2414            .when_matched_update(&[("tenant_id", Expr::Named("s.tenant_id".to_string()))])
2415            .with_rls(&ctx)
2416            .expect_err("MERGE tenant column updates must fail closed");
2417
2418        assert!(err.to_string().contains("tenant column mutation"));
2419    }
2420
2421    #[test]
2422    fn test_with_rls_global_scopes_merge_query_source() {
2423        seal_tenant_table("_rls_global_merge_query_target", "tenant_id");
2424        seal_tenant_table("_rls_global_merge_query_source", "tenant_id");
2425
2426        let source = Qail::get("_rls_global_merge_query_source").columns(["id", "name"]);
2427        let query = Qail::merge_into("_rls_global_merge_query_target")
2428            .using_query_as(source, "s")
2429            .merge_on_column("_rls_global_merge_query_target.id", Operator::Eq, "s.id")
2430            .when_not_matched_insert(
2431                &["id", "name"],
2432                &[
2433                    Expr::Named("s.id".to_string()),
2434                    Expr::Named("s.name".to_string()),
2435                ],
2436            )
2437            .with_rls(&RlsContext::global())
2438            .expect("global merge rls should apply");
2439
2440        let merge = query.merge.as_ref().expect("merge spec");
2441        let MergeSource::Query {
2442            query: source_query,
2443            ..
2444        } = &merge.source
2445        else {
2446            panic!("expected query source");
2447        };
2448        assert!(
2449            source_query.cages.iter().any(|cage| {
2450                matches!(cage.kind, CageKind::Filter)
2451                    && cage.conditions.iter().any(|condition| {
2452                        matches!(&condition.left, Expr::Named(name) if name.ends_with("tenant_id"))
2453                            && condition.op == Operator::IsNull
2454                            && matches!(condition.value, Value::Null)
2455                    })
2456            }),
2457            "global MERGE query source must be scoped to NULL tenant rows"
2458        );
2459
2460        let sql = query.to_sql();
2461        assert!(
2462            sql.contains("ON _rls_global_merge_query_target.id = s.id AND _rls_global_merge_query_target.tenant_id = s.tenant_id"),
2463            "global MERGE query source ON must include target/source tenant equality: {sql}"
2464        );
2465        assert!(
2466            sql.contains("WHEN NOT MATCHED BY TARGET AND s.tenant_id IS NULL THEN INSERT"),
2467            "global MERGE query source insert branch must be source-tenant scoped: {sql}"
2468        );
2469    }
2470
2471    #[test]
2472    fn test_with_rls_rejects_merge_query_source_without_tenant_projection() {
2473        seal_tenant_table("_rls_merge_aggregate_target", "tenant_id");
2474        seal_tenant_table("_rls_merge_aggregate_source", "tenant_id");
2475
2476        let mut source = Qail::get("_rls_merge_aggregate_source");
2477        source.columns.push(Expr::Aggregate {
2478            col: "*".to_string(),
2479            func: crate::ast::AggregateFunc::Count,
2480            distinct: false,
2481            filter: None,
2482            alias: Some("total".to_string()),
2483        });
2484
2485        let err = Qail::merge_into("_rls_merge_aggregate_target")
2486            .target_alias("t")
2487            .using_query_as(source, "s")
2488            .merge_on_column("t.id", Operator::Eq, "s.id")
2489            .when_not_matched_insert(&["id"], &[Expr::Named("s.id".to_string())])
2490            .with_rls(&RlsContext::tenant("tenant-aggregate"))
2491            .expect_err("aggregate query source without tenant projection must fail closed");
2492
2493        assert!(err.to_string().contains("MERGE query sources"));
2494    }
2495
2496    #[test]
2497    fn test_with_rls_scopes_merge_by_source_delete_without_target_only_on_predicate() {
2498        seal_tenant_table("_rls_merge_prune_orders", "tenant_id");
2499        seal_tenant_table("_rls_merge_prune_source_orders", "tenant_id");
2500
2501        let ctx = RlsContext::tenant("tenant-prune");
2502        let query = Qail::merge_into("_rls_merge_prune_orders")
2503            .target_alias("t")
2504            .using_table_as("_rls_merge_prune_source_orders", "s")
2505            .merge_on_column("t.id", Operator::Eq, "s.id")
2506            .when_not_matched_by_source_delete()
2507            .with_rls(&ctx)
2508            .expect("merge rls should apply");
2509
2510        let sql = query.to_sql();
2511        assert!(
2512            sql.contains("ON t.id = s.id AND t.tenant_id = s.tenant_id"),
2513            "MERGE ON should use target/source tenant equality, not a target-only literal: {sql}"
2514        );
2515        assert!(
2516            sql.contains("WHEN NOT MATCHED BY SOURCE AND t.tenant_id = 'tenant-prune' THEN DELETE"),
2517            "BY SOURCE delete must be target-tenant scoped in the WHEN branch: {sql}"
2518        );
2519        assert!(
2520            !sql.contains("ON t.id = s.id AND t.tenant_id = 'tenant-prune'"),
2521            "target-only tenant predicates in ON can misclassify BY SOURCE rows: {sql}"
2522        );
2523    }
2524
2525    #[test]
2526    fn test_with_rls_global_scopes_merge_to_null_tenant() {
2527        seal_tenant_table("_rls_global_merge_catalog", "tenant_id");
2528        seal_tenant_table("_rls_global_merge_source", "tenant_id");
2529
2530        let query = Qail::merge_into("_rls_global_merge_catalog")
2531            .using_table_as("_rls_global_merge_source", "s")
2532            .merge_on_column("_rls_global_merge_catalog.id", Operator::Eq, "s.id")
2533            .when_not_matched_insert(
2534                &["id", "name"],
2535                &[
2536                    Expr::Named("s.id".to_string()),
2537                    Expr::Named("s.name".to_string()),
2538                ],
2539            )
2540            .with_rls(&RlsContext::global())
2541            .expect("global merge rls should apply");
2542
2543        let sql = query.to_sql();
2544        assert!(
2545            sql.contains(
2546                "ON _rls_global_merge_catalog.id = s.id AND _rls_global_merge_catalog.tenant_id = s.tenant_id"
2547            ),
2548            "global MERGE ON must preserve target/source tenant equality: {sql}"
2549        );
2550        assert!(
2551            sql.contains("WHEN NOT MATCHED BY TARGET AND s.tenant_id IS NULL THEN INSERT"),
2552            "global MERGE insert branch must be source-null scoped: {sql}"
2553        );
2554        assert!(
2555            sql.contains("INSERT (id, name, tenant_id) VALUES (s.id, s.name, NULL)"),
2556            "global MERGE insert branch must include NULL tenant: {sql}"
2557        );
2558    }
2559
2560    #[test]
2561    fn test_with_rls_is_idempotent_on_filter_scope() {
2562        seal_tenant_table("_rls_idempotent_get_orders", "tenant_id");
2563
2564        let ctx = RlsContext::tenant("t-idempotent");
2565        let query = Qail::get("_rls_idempotent_get_orders")
2566            .with_rls(&ctx)
2567            .expect("rls should apply")
2568            .with_rls(&ctx);
2569        let query = query.expect("rls should remain idempotent");
2570
2571        let filter = query
2572            .cages
2573            .iter()
2574            .find(|c| matches!(c.kind, CageKind::Filter))
2575            .expect("filter cage");
2576
2577        let tenant_matches = filter
2578            .conditions
2579            .iter()
2580            .filter(|c| matches!(&c.left, Expr::Named(n) if n.ends_with("tenant_id")))
2581            .count();
2582        assert_eq!(tenant_matches, 1, "tenant scope should not duplicate");
2583    }
2584
2585    #[test]
2586    fn test_with_rls_add_positional_payload_aligns_insert_columns() {
2587        seal_tenant_table("_rls_positional_add_orders", "tenant_id");
2588
2589        let ctx = RlsContext::tenant("tenant-positional");
2590        let query = Qail::add("_rls_positional_add_orders")
2591            .columns(["id", "total"])
2592            .values([Value::Int(1), Value::Int(100)])
2593            .with_rls(&ctx)
2594            .expect("rls should apply");
2595
2596        let sql = query.to_sql();
2597        assert!(
2598            sql.contains("tenant_id"),
2599            "tenant column should be injected"
2600        );
2601        assert!(
2602            sql.contains("VALUES (1, 100, 'tenant-positional')"),
2603            "insert payload should include injected tenant value in positional order: {sql}"
2604        );
2605    }
2606
2607    #[test]
2608    fn test_with_rls_add_positional_payload_overrides_existing_tenant_column_value() {
2609        seal_tenant_table("_rls_positional_add_override_orders", "tenant_id");
2610
2611        let ctx = RlsContext::tenant("tenant-final");
2612        let query = Qail::add("_rls_positional_add_override_orders")
2613            .columns(["id", "tenant_id", "total"])
2614            .values([
2615                Value::Int(1),
2616                Value::String("tenant-wrong".to_string()),
2617                Value::Int(50),
2618            ])
2619            .with_rls(&ctx)
2620            .expect("rls should apply");
2621
2622        let sql = query.to_sql();
2623        assert!(sql.contains("'tenant-final'"));
2624        assert!(!sql.contains("'tenant-wrong'"));
2625    }
2626
2627    #[test]
2628    fn test_with_rls_add_positional_payload_without_columns_errors() {
2629        seal_tenant_table("_rls_positional_add_without_columns_orders", "tenant_id");
2630
2631        let ctx = RlsContext::tenant("tenant-without-columns");
2632        let err = Qail::add("_rls_positional_add_without_columns_orders")
2633            .values([Value::Int(1), Value::Int(100)])
2634            .with_rls(&ctx)
2635            .expect_err("positional payload without columns should fail");
2636
2637        assert!(err.to_string().contains("requires explicit columns"));
2638    }
2639
2640    #[test]
2641    fn test_with_rls_replaces_qualified_tenant_filter() {
2642        seal_tenant_table("_rls_qualified_tenant_filter_orders", "tenant_id");
2643
2644        let ctx = RlsContext::tenant("tenant-final");
2645        let query = Qail::get("_rls_qualified_tenant_filter_orders")
2646            .filter("orders.tenant_id", Operator::Eq, "tenant-wrong")
2647            .with_rls(&ctx)
2648            .expect("rls should apply");
2649
2650        let sql = query.to_sql();
2651        assert!(sql.contains("'tenant-final'"));
2652        assert!(!sql.contains("'tenant-wrong'"));
2653    }
2654    // ── 42702 regression: injected predicates must be table-qualified ──
2655
2656    #[test]
2657    fn tenant_injection_is_qualified_on_joined_get() {
2658        seal_tenant_table("_rls_q_articles", "tenant_id");
2659        seal_tenant_table("_rls_q_authors", "tenant_id");
2660        let ctx = RlsContext::tenant("t-1");
2661        let cmd = Qail::get("_rls_q_articles")
2662            .columns(["_rls_q_articles.id", "_rls_q_authors.name"])
2663            .inner_join(
2664                "_rls_q_authors",
2665                "_rls_q_articles.author_id",
2666                "_rls_q_authors.id",
2667            )
2668            .with_rls(&ctx)
2669            .expect("with_rls");
2670        let sql = cmd.to_sql();
2671        // A bare `tenant_id = $x` is ambiguous the moment another joined
2672        // table carries the column — Postgres 42702. The primary predicate
2673        // must name its relation.
2674        assert!(
2675            sql.contains("_rls_q_articles.tenant_id"),
2676            "primary tenant predicate must be table-qualified: {sql}"
2677        );
2678    }
2679
2680    #[test]
2681    fn tenant_injection_is_qualified_on_update_and_delete() {
2682        seal_tenant_table("_rls_q_upd", "tenant_id");
2683        let ctx = RlsContext::tenant("t-1");
2684        let upd = Qail::set("_rls_q_upd")
2685            .set_value("x", 1)
2686            .with_rls(&ctx)
2687            .expect("with_rls")
2688            .to_sql();
2689        assert!(
2690            upd.contains("_rls_q_upd.tenant_id"),
2691            "UPDATE tenant predicate must be table-qualified: {upd}"
2692        );
2693        let del = Qail::del("_rls_q_upd")
2694            .eq("id", "r-1")
2695            .with_rls(&ctx)
2696            .expect("with_rls")
2697            .to_sql();
2698        assert!(
2699            del.contains("_rls_q_upd.tenant_id"),
2700            "DELETE tenant predicate must be table-qualified: {del}"
2701        );
2702    }
2703
2704    #[test]
2705    fn global_scope_injection_is_qualified_on_joined_get() {
2706        seal_tenant_table("_rls_q_globals", "tenant_id");
2707        seal_tenant_table("_rls_q_globals_kin", "tenant_id");
2708        let ctx = RlsContext::global();
2709        let sql = Qail::get("_rls_q_globals")
2710            .inner_join(
2711                "_rls_q_globals_kin",
2712                "_rls_q_globals.kin_id",
2713                "_rls_q_globals_kin.id",
2714            )
2715            .with_rls(&ctx)
2716            .expect("with_rls")
2717            .to_sql();
2718        assert!(
2719            sql.contains("_rls_q_globals.tenant_id"),
2720            "global IS NULL predicate must be table-qualified: {sql}"
2721        );
2722    }
2723
2724    #[test]
2725    fn schema_qualified_injection_dedups_bare_and_relation_qualified_predicates() {
2726        // rc.2 P1: the injected `public.orders.tenant_id` must SUPERSEDE a
2727        // caller-supplied bare `tenant_id` (and an `orders.tenant_id`) on the
2728        // same relation — not coexist with it.
2729        for existing in ["tenant_id", "_rls_sq_orders.tenant_id"] {
2730            seal_tenant_table("public._rls_sq_orders", "tenant_id");
2731            let ctx = RlsContext::tenant("t-1");
2732            let cmd = Qail::get("public._rls_sq_orders")
2733                .eq(existing, "t-1")
2734                .with_rls(&ctx)
2735                .expect("with_rls");
2736            let scope_predicates = cmd
2737                .cages
2738                .iter()
2739                .filter(|c| matches!(c.kind, CageKind::Filter))
2740                .flat_map(|c| c.conditions.iter())
2741                .filter(|cond| matches!(&cond.left, Expr::Named(n) if n.ends_with("tenant_id")))
2742                .count();
2743            assert_eq!(
2744                scope_predicates,
2745                1,
2746                "exactly one scope predicate must remain for existing={existing}: {}",
2747                cmd.to_sql()
2748            );
2749        }
2750    }
2751
2752    #[test]
2753    fn identical_set_value_after_injection_collapses_to_one_entry() {
2754        // with_rls FIRST (payload injection), identical explicit stamp AFTER
2755        // — the production metering shape. The idempotent duplicate collapses
2756        // to exactly one tenant_id payload entry.
2757        seal_tenant_table("_rls_sv_ledger", "tenant_id");
2758        let ctx = RlsContext::tenant("t-ctx");
2759        let cmd = Qail::add("_rls_sv_ledger")
2760            .with_rls(&ctx)
2761            .expect("with_rls")
2762            .set_value("tenant_id", "t-ctx")
2763            .set_value("metric", "core_transactions");
2764        let payload_tenants = cmd
2765            .cages
2766            .iter()
2767            .filter(|c| matches!(c.kind, CageKind::Payload))
2768            .flat_map(|c| c.conditions.iter())
2769            .filter(|cond| matches!(&cond.left, Expr::Named(n) if n == "tenant_id"))
2770            .count();
2771        assert_eq!(payload_tenants, 1, "{}", cmd.to_sql());
2772        assert!(cmd.to_sql().contains("'t-ctx'"));
2773    }
2774
2775    #[test]
2776    fn schema_qualified_cross_join_keeps_the_joined_scope_predicate() {
2777        // A REAL cross join (JoinKind::Cross, on: None): its scope predicate
2778        // lands in the filter cage where the primary injection de-dup runs —
2779        // exactly the path that deleted it before the joined qualifiers were
2780        // last-segment normalized. (An INNER join's predicate lives in ON
2781        // and never exercises this path.)
2782        seal_tenant_table("public._rls_sqj_a", "tenant_id");
2783        seal_tenant_table("public._rls_sqj_b", "tenant_id");
2784        let ctx = RlsContext::tenant("t-1");
2785        let mut q = Qail::get("public._rls_sqj_a");
2786        q.joins.push(crate::ast::Join {
2787            table: "public._rls_sqj_b b".to_string(),
2788            kind: JoinKind::Cross,
2789            on: None,
2790            on_true: true,
2791        });
2792        let sql = q.with_rls(&ctx).expect("with_rls").to_sql();
2793        assert!(
2794            sql.contains("_rls_sqj_a.tenant_id = 't-1'"),
2795            "primary predicate must survive: {sql}"
2796        );
2797        assert!(
2798            sql.contains("b.tenant_id = 't-1'"),
2799            "cross-joined predicate must survive primary injection de-dup: {sql}"
2800        );
2801    }
2802
2803    #[test]
2804    fn schema_qualified_cross_join_keeps_the_joined_scope_predicate_under_global() {
2805        seal_tenant_table("public._rls_sqjg_a", "tenant_id");
2806        seal_tenant_table("public._rls_sqjg_b", "tenant_id");
2807        let mut q = Qail::get("public._rls_sqjg_a");
2808        q.joins.push(crate::ast::Join {
2809            table: "public._rls_sqjg_b b".to_string(),
2810            kind: JoinKind::Cross,
2811            on: None,
2812            on_true: true,
2813        });
2814        let sql = q
2815            .with_rls(&RlsContext::global())
2816            .expect("with_rls")
2817            .to_sql();
2818        assert!(
2819            sql.contains("_rls_sqjg_a.tenant_id IS NULL"),
2820            "primary IS NULL predicate must survive: {sql}"
2821        );
2822        assert!(
2823            sql.contains("b.tenant_id IS NULL"),
2824            "cross-joined IS NULL predicate must survive: {sql}"
2825        );
2826    }
2827
2828    #[test]
2829    fn identical_stamp_collapses_in_both_call_orders() {
2830        seal_tenant_table("_rls_dup_orders_a", "tenant_id");
2831        let ctx = RlsContext::tenant("t-9");
2832        // with_rls first, identical stamp after.
2833        let first = Qail::add("_rls_dup_orders_a")
2834            .with_rls(&ctx)
2835            .expect("with_rls")
2836            .set_value("tenant_id", "t-9")
2837            .set_value("x", 1);
2838        // stamp first, with_rls after (injection replaces).
2839        let second = Qail::add("_rls_dup_orders_a")
2840            .set_value("tenant_id", "t-9")
2841            .set_value("x", 1)
2842            .with_rls(&ctx)
2843            .expect("with_rls");
2844        for (label, cmd) in [("rls-first", first), ("stamp-first", second)] {
2845            let n = cmd
2846                .cages
2847                .iter()
2848                .filter(|c| matches!(c.kind, CageKind::Payload))
2849                .flat_map(|c| c.conditions.iter())
2850                .filter(|cond| matches!(&cond.left, Expr::Named(n) if n == "tenant_id"))
2851                .count();
2852            assert_eq!(n, 1, "{label}: {}", cmd.to_sql());
2853        }
2854    }
2855
2856    #[test]
2857    fn conflicting_stamp_after_injection_is_preserved_for_the_encoder_error() {
2858        // A later set_value must NOT silently override the injected scope —
2859        // the conflicting duplicate survives so the encoder's
2860        // assigns-column-more-than-once error stays fail-closed.
2861        seal_tenant_table("_rls_dup_orders_b", "tenant_id");
2862        let ctx = RlsContext::tenant("t-real");
2863        let cmd = Qail::add("_rls_dup_orders_b")
2864            .with_rls(&ctx)
2865            .expect("with_rls")
2866            .set_value("tenant_id", "t-spoof");
2867        let n = cmd
2868            .cages
2869            .iter()
2870            .filter(|c| matches!(c.kind, CageKind::Payload))
2871            .flat_map(|c| c.conditions.iter())
2872            .filter(|cond| matches!(&cond.left, Expr::Named(n) if n == "tenant_id"))
2873            .count();
2874        assert_eq!(n, 2, "conflicting duplicate must be preserved");
2875    }
2876
2877    #[test]
2878    fn owner_scope_stamp_follows_the_same_idempotence_rules() {
2879        seal_owner_table("_rls_dup_owner", "user_id");
2880        let ctx = RlsContext::user("u-1");
2881        let same = Qail::add("_rls_dup_owner")
2882            .with_rls(&ctx)
2883            .expect("with_rls")
2884            .set_value("user_id", "u-1");
2885        let count = |cmd: &Qail| {
2886            cmd.cages
2887                .iter()
2888                .filter(|c| matches!(c.kind, CageKind::Payload))
2889                .flat_map(|c| c.conditions.iter())
2890                .filter(|cond| matches!(&cond.left, Expr::Named(n) if n == "user_id"))
2891                .count()
2892        };
2893        assert_eq!(count(&same), 1);
2894        let conflicting = Qail::add("_rls_dup_owner")
2895            .with_rls(&ctx)
2896            .expect("with_rls")
2897            .set_value("user_id", "u-2");
2898        assert_eq!(count(&conflicting), 2);
2899    }
2900
2901    #[test]
2902    fn conflicting_set_coalesce_after_injection_remains_fail_closed() {
2903        // set_coalesce wraps the value (COALESCE expression), so against the
2904        // injected plain tenant value it is a CONFLICTING duplicate — both
2905        // entries must survive for the encoder error to fire.
2906        seal_tenant_table("_rls_dup_coalesce", "tenant_id");
2907        let ctx = RlsContext::tenant("t-c");
2908        let cmd = Qail::add("_rls_dup_coalesce")
2909            .with_rls(&ctx)
2910            .expect("with_rls")
2911            .set_coalesce("tenant_id", "t-c");
2912        let n = cmd
2913            .cages
2914            .iter()
2915            .filter(|c| matches!(c.kind, CageKind::Payload))
2916            .flat_map(|c| c.conditions.iter())
2917            .filter(|cond| matches!(&cond.left, Expr::Named(n) if n == "tenant_id"))
2918            .count();
2919        assert_eq!(
2920            n,
2921            2,
2922            "conflicting set_coalesce must be preserved: {}",
2923            cmd.to_sql()
2924        );
2925    }
2926}