Skip to main content

boatramp_types/
tenancy.rs

1//! In-site tenancy configuration — the declared side of the tenant-isolation model.
2//!
3//! An app **opts into** in-site sub-tenancy per function/site by declaring a [`Tenancy::Scoped`]
4//! block: a tenant column + a host-verified [`TenantSource`] + per-axis [`AccessMode`] grants.
5//! A config with **no** [`Tenancy`] block means *undeclared*; [`Tenancy::Disabled`] means
6//! *deliberately no in-site tenancy* — plain queries, where the project=database boundary is the
7//! entire isolation. The distinction matters only under the `multi-tenant` operator posture,
8//! which **requires** an explicit decision (`disabled` or `scoped`) so running plain is a
9//! reviewed choice, never an accidental omission; `single-tenant`/`dev` treat *undeclared* as
10//! `disabled` silently.
11//!
12//! This module carries only the wasm-clean *declaration*. Resolving the tenant **value** from the
13//! source and building the injected row scope happens above (host-side), where `SqlValue` lives.
14
15use std::collections::{BTreeMap, BTreeSet};
16
17use serde::de::{self, Deserializer, MapAccess, SeqAccess, Visitor};
18use serde::{Deserialize, Serialize};
19
20/// How the host resolves an app's in-site "own" tenant for a request. Every source is
21/// **host-verified** and bound once per invocation; a guest never supplies the tenant value.
22#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(tag = "kind", rename_all = "snake_case")]
24#[derive(Default)]
25pub enum TenantSource {
26    /// The verified JWT claim named `claim` (default `tid`), via the same JWKS/issuer machinery
27    /// the GraphQL data connector uses. Authenticated console/portal paths.
28    Token {
29        #[serde(default = "default_tid_claim")]
30        claim: String,
31    },
32    /// Derived from the already-verified request domain via its per-domain context tag
33    /// ([`crate::project::DomainOwner`]'s context). Storefront / public-render paths — no
34    /// app-side `Host`→slug lookup.
35    Domain,
36    /// A host-verifiable signed-context envelope carried on a job/message — the **async-lane "own"**
37    /// (message consumers, cron, webhooks, workflow steps, fan-out workers), the async analog of
38    /// `Token`. The producer's verified own-tenant is host-stamped onto the outgoing
39    /// message/invoke at publish (guest-blind, [`boatramp_core::cose::mint_context`]); a consumer
40    /// declaring this source resolves that tenant by verifying the envelope against the fleet anchor.
41    /// A forged/expired/absent envelope resolves no value ⇒ an "own" op fails closed (never
42    /// unscoped). The guest never names a tenant. Wired since v0.4.3 (producer-stamp at publish +
43    /// consumer resolution in `tenant_resolve.rs`).
44    SignedContext,
45    /// Truly anonymous / non-token auth (funnel reads, HMAC webhooks): there is no "own" tenant,
46    /// so only the `null`/`all` access modes are meaningful (an "own" mode fails closed).
47    #[default]
48    None,
49}
50
51fn default_tid_claim() -> String {
52    "tid".to_string()
53}
54
55/// The default source list when a `Scoped` block omits it: truly anonymous (`[None]`) — an "own"
56/// grant then fails closed until a source is declared.
57fn default_sources() -> Vec<TenantSource> {
58    vec![TenantSource::None]
59}
60
61/// Deserialize the [`Tenancy::Scoped`] `sources` list, accepting BOTH the Stage-2 list form
62/// (`sources: [ (kind: token), (kind: domain) ]` — priority-ordered per-trigger sources) AND — for
63/// backward compatibility with the pre-Stage-2 singular `source:` field (v0.4.0) — a single source
64/// written as one map (`source: (kind: token)`), which becomes a one-element list. Uses
65/// `deserialize_any` (the wire format is self-describing) so a seq → many and a map → the singleton,
66/// **without** an `untagged` enum (which RON — the manifest format — handles poorly). A pre-Stage-2
67/// config therefore keeps resolving exactly as before.
68fn de_sources<'de, D>(deserializer: D) -> Result<Vec<TenantSource>, D::Error>
69where
70    D: Deserializer<'de>,
71{
72    struct SourcesVisitor;
73    impl<'de> Visitor<'de> for SourcesVisitor {
74        type Value = Vec<TenantSource>;
75        fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
76            f.write_str("a TenantSource map or a list of TenantSource maps")
77        }
78        fn visit_seq<A: SeqAccess<'de>>(self, mut seq: A) -> Result<Self::Value, A::Error> {
79            let mut out = Vec::new();
80            while let Some(s) = seq.next_element::<TenantSource>()? {
81                out.push(s);
82            }
83            Ok(out)
84        }
85        fn visit_map<A: MapAccess<'de>>(self, map: A) -> Result<Self::Value, A::Error> {
86            // A single source written as a struct-map (the legacy `source:` singular).
87            let s = TenantSource::deserialize(de::value::MapAccessDeserializer::new(map))?;
88            Ok(vec![s])
89        }
90    }
91    deserializer.deserialize_any(SourcesVisitor)
92}
93
94/// A **closed** set of the host-verified/host-resolved sources for the *target* axis (R4/D8) — a
95/// SECOND tenant `B` (≠ the caller's own tenant `A`), used only to read `B`'s deliberately-published
96/// PUBLIC subset. Deliberately **distinct** from [`TenantSource`] so `Handle` (a public slug) is
97/// *unrepresentable* on the own/session/private axes at the type level — a guest can never name its
98/// OWN tenant, only a public target, and only within the guardrails (G1–G6). `via` is a
99/// priority-ordered list of these, homogeneous in tier by construction.
100#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
101#[serde(rename_all = "snake_case")]
102#[non_exhaustive]
103pub enum TargetSource {
104    /// The terminating request domain, host-verified (same-origin, write-capable) — a world-public
105    /// funnel served on the tenant's own host. Tier-2 (published storefront/directory).
106    Domain,
107    /// A public **slug/handle** passed from a third-party origin (embed / aggregator / preview):
108    /// **READ-ONLY (G1)** and admissible **only** on a `world_public` subset (G2). It names public
109    /// data, so it grants nothing an anonymous GET of that data wouldn't. Never on a write field.
110    Handle,
111    /// A host-verified **capability token** carrying the target facts (`tid`, `sub`): the token is
112    /// the *authorization* (tier-3 embed/handoff, NOT world-public), so it is never mixed with
113    /// `Handle` and can back a target write.
114    Capability,
115}
116
117/// The tenancy **class** a root Query/Mutation field (or a plain-wasm route) runs under (R4/D8),
118/// composed from the trusted SDL `@tenant` directive at publish and gated by the operator's
119/// [`TenancySchema::target_eligible_fields`]. Looked up by the host planner and bound **before the
120/// guest runs** — there is no request-time parameter expressing own-vs-target, so a guest can never
121/// select or detect which scope it got (picking the wrong scope is *unrepresentable*). Absent ⇒
122/// `Own` (byte-identical to pre-Stage-5).
123#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Default)]
124#[serde(tag = "scope", rename_all = "snake_case")]
125#[non_exhaustive]
126pub enum TenancyClass {
127    /// The caller's OWN resolved tenant/session (today's behavior).
128    #[default]
129    Own,
130    /// A SECOND tenant `B`'s PUBLIC subset. `via` is the prioritized target-source list
131    /// (first-resolves-wins); `public` NAMES the host-held public subset to confine to; `write` is
132    /// the (deny-by-default, empty ⇒ read-only) SET-allowlist of columns a target write may set.
133    Target {
134        via: Vec<TargetSource>,
135        public: String,
136        #[serde(default)]
137        write: Vec<String>,
138        /// **`target_or_null`** (the target-axis analog of [`AccessMode::OwnOrNull`]): when `true`, a
139        /// target READ confines to `(<tenant col> = B OR <tenant col> IS NULL) AND <public subset>` —
140        /// `B`'s public rows PLUS the shared `NULL`-tenant **base/reference** rows (the inheritance
141        /// floor a public funnel needs) — instead of `tenant = B` alone. Read-only: a target WRITE
142        /// still stamps `B` (never the base). The `NULL` disjunct is added ONLY on plain tenant
143        /// (`Column`) tables, never a `TenantOrSession` table (its `NULL` partition is session rows,
144        /// not shared base) nor an `Unscoped` global. Surfaced in the SDL as `scope: target_or_null`.
145        #[serde(default)]
146        null_base: bool,
147    },
148}
149
150impl TenancyClass {
151    /// Whether this class reads/writes another tenant (target axis) vs. the caller's own.
152    pub fn is_target(&self) -> bool {
153        matches!(self, Self::Target { .. })
154    }
155}
156
157/// One host-held visibility term of a [`PublicPredicate`] — `column <op> literal` or a null test.
158/// Never a DSL, never guest-authored or claim-bound: a fixed shape over a literal only.
159#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
160#[serde(tag = "kind", rename_all = "snake_case")]
161pub enum PublicTerm {
162    /// `column <op> <value>` (e.g. `published = true`).
163    Cmp {
164        column: String,
165        op: PublicCmp,
166        value: PublicLiteral,
167    },
168    /// `column IS [NOT] NULL` (e.g. `deleted_at IS NULL`).
169    Null { column: String, negated: bool },
170}
171
172/// The comparison operators a [`PublicTerm`] may use (a closed set — visibility predicates only).
173#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
174#[serde(rename_all = "snake_case")]
175pub enum PublicCmp {
176    Eq,
177    Ne,
178    Lt,
179    Le,
180    Gt,
181    Ge,
182}
183
184/// A literal a [`PublicTerm`] compares against. Types-local (this crate is `SqlValue`-free —
185/// boatramp-core lowers it to a bound `SqlValue` at injection, so the literal is always a parameter,
186/// never interpolated text).
187#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
188#[serde(rename_all = "snake_case")]
189pub enum PublicLiteral {
190    Bool(bool),
191    Int(i64),
192    Text(String),
193}
194
195/// A host-held definition of a table's PUBLIC rows (R4/D8): a **closed conjunction** of visibility
196/// terms (`published = true AND deleted_at IS NULL`). A target READ conjoins it (never sees a
197/// non-public row of `B`); a target WRITE filters on it (a non-public row is a fail-closed no-op).
198/// Host-held per table — never a guest-authored DSL — so a guest can never widen its own visibility.
199#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
200pub struct PublicPredicate {
201    /// The conjoined terms (AND). Empty is legal but meaningless (matches every row) — the schema
202    /// loader should reject an empty public predicate on a `world_public` subset.
203    pub terms: Vec<PublicTerm>,
204}
205
206/// A named PUBLIC subset (R4/D8): a table's [`PublicPredicate`] plus the two **separate**,
207/// deny-by-default, operator-held flags that gate the least-trusted target sources.
208#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
209#[serde(default, deny_unknown_fields)]
210pub struct PublicSubset {
211    /// The visibility predicate confining a target read/write to public rows.
212    pub predicate: PublicPredicate,
213    /// **G2** — is this subset readable by an anonymous `Handle` (public slug) at all? A SEPARATE,
214    /// explicit flag, NOT "has a public subset" (every target field has one, incl. tier-3 capability
215    /// data). `false` (default) ⇒ `handle` is refused on this subset at composition, regardless of a
216    /// field's declared `via` list — so a tier-2 handle can never reach tier-3 data.
217    pub world_public: bool,
218    /// Whether this table's tenants are discoverable by a `handle` lookup (`SELECT tenant WHERE slug
219    /// = ? AND listable = true`). Directory-scraping of listable tenants is confidentiality-neutral
220    /// (that is what listable means) but rate-limited (G5). `false` (default) ⇒ no handle resolves.
221    pub listable: bool,
222}
223
224/// Which **axis** a resolved tenant fact belongs to (`PLAN-tenancy-principal` D1). The host-resolved
225/// principal is a small *set* of facts, each tagged with its axis, so an inherited/carried principal
226/// preserves which axis a value belongs to (e.g. an inherited `TargetTenant` fact keeps its
227/// public-subset confinement, never collapsing into an `own` `Tenant` fact).
228///
229/// **CLOSED enum — the line.** Only these three axes exist, ever: `Tenant` (the caller's own tenant,
230/// Stage 2), `Session` (an anonymous-identity disjunct, Stage 3), `TargetTenant` (one *other*
231/// tenant's public subset, Stage 5). A fourth axis is a design smell — extend a table's scope class
232/// or a fact's lifetime instead. `#[non_exhaustive]` only so the later stages can land their variants
233/// without a breaking change for downstream crates; it is not an invitation to add a fourth.
234#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
235#[serde(rename_all = "snake_case")]
236#[non_exhaustive]
237pub enum ScopeAxis {
238    /// The caller's own tenant — resolved per trigger from a [`TenantSource`] (Stage 2).
239    Tenant,
240    /// An anonymous returning-visitor identity, a disjunct of `Tenant` (Stage 3).
241    Session,
242    /// One *other* tenant, read-only, confined to a host-declared public subset (Stage 5).
243    TargetTenant,
244}
245
246/// Which tenant-set one axis (read or write) of a function may reach. **Default-deny** on
247/// cross-tenant: only [`AccessMode::All`] crosses tenants, and it needs the operator posture
248/// ceiling to permit it. Distinguishes every case: own, own+null, null-only, all, and no access.
249#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
250#[serde(rename_all = "snake_case")]
251pub enum AccessMode {
252    /// No access at all on this axis (deny).
253    None,
254    /// The shared baseline only (`<column> IS NULL`).
255    Null,
256    /// The resolved tenant only.
257    Own,
258    /// The resolved tenant plus the shared baseline.
259    OwnOrNull,
260    /// Cross-tenant — all rows. Gated by the operator posture ceiling.
261    All,
262}
263
264impl AccessMode {
265    /// Whether this mode crosses tenants (so it needs the operator ceiling to be permitted).
266    pub fn is_cross_tenant(self) -> bool {
267        matches!(self, Self::All)
268    }
269    /// Whether this mode needs a resolved "own" tenant value (so an unresolvable source ⇒ deny).
270    pub fn needs_own_value(self) -> bool {
271        matches!(self, Self::Own | Self::OwnOrNull)
272    }
273    /// Whether the row-set this mode reaches is a **subset** of `ceiling`'s row-set — i.e. this
274    /// mode is a narrowing (or equal), never a widening. Used to enforce that a per-component
275    /// access grant stays within its site ceiling. The modes form a subset lattice, not a total
276    /// order: `Own` and `Null` are disjoint (neither contains the other), so this is a real
277    /// containment test, not a rank comparison.
278    pub fn within(self, ceiling: Self) -> bool {
279        use AccessMode::*;
280        match self {
281            None => true, // {} ⊆ anything
282            Null => matches!(ceiling, Null | OwnOrNull | All),
283            Own => matches!(ceiling, Own | OwnOrNull | All),
284            OwnOrNull => matches!(ceiling, OwnOrNull | All),
285            All => matches!(ceiling, All),
286        }
287    }
288}
289
290fn default_own() -> AccessMode {
291    AccessMode::Own
292}
293
294/// Deserialize an `Option<Tenancy>` config field from **either RON or JSON**, via a `ron::Value`
295/// bridge.
296///
297/// `Tenancy` is an internally-tagged enum (`tag = "mode"`) whose variants carry **enum-valued
298/// fields** (`read`/`write` = [`AccessMode`], `sources` = [`TenantSource`], `via` =
299/// [`TargetSource`]). serde's internally-tagged deserialization buffers the content through
300/// `deserialize_any`, and RON's `deserialize_any` collapses a bare/nested enum value to a *unit*
301/// — so a direct `Tenancy::deserialize` from RON fails (`expected variant identifier, found a unit
302/// value`) in every spelling. serde_json has no such issue.
303///
304/// Routing through [`ron::Value`] — a faithful intermediate that both the RON and the serde_json
305/// deserializers populate correctly — and reconstructing with [`ron::Value::into_rust`] parses one
306/// canonical spelling everywhere:
307/// `(mode: "scoped", column: "tenant_id", sources: [(kind: "token", claim: "tid")], read: "own",
308/// write: "own")` in `project.cfg`/`apply.cfg`, and the byte-identical `{"mode":"scoped",…}` the
309/// control plane stores. (Serialization is unaffected — the derived `Serialize` still emits the
310/// internally-tagged form.)
311pub fn de_opt_tenancy<'de, D>(deserializer: D) -> Result<Option<Tenancy>, D::Error>
312where
313    D: serde::Deserializer<'de>,
314{
315    use serde::Deserialize;
316    match Option::<ron::Value>::deserialize(deserializer)? {
317        Some(value) => value
318            .into_rust::<Tenancy>()
319            .map(Some)
320            .map_err(serde::de::Error::custom),
321        None => Ok(None),
322    }
323}
324
325/// A function/site's in-site tenancy decision. Its **presence** (`Some`) is the explicit
326/// "I decided about tenancy" signal the `multi-tenant` posture requires; absence (`None`) means
327/// *undeclared*.
328#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
329#[serde(tag = "mode", rename_all = "snake_case")]
330pub enum Tenancy {
331    /// Deliberately no in-site tenancy — plain queries (the project=database boundary is the whole
332    /// isolation). The explicit "single-tenant / no tenancy" declaration.
333    Disabled,
334    /// In-site sub-tenancy on `column`, resolving "own" from the first applicable `sources` entry,
335    /// at the per-axis grants.
336    Scoped {
337        /// The tenant column the host scopes on (e.g. `tenant_id`). Validated as a SQL identifier
338        /// when the scope is applied.
339        column: String,
340        /// The host-verified sources the "own" tenant may resolve from, in **priority order** — the
341        /// host picks the first whose current-trigger input is present (a `token` on an authenticated
342        /// HTTP request, the routed `domain` on a storefront, a `signed_context` on an async job), so
343        /// one component can serve multiple trigger kinds (`PLAN-tenancy-principal` R1). Accepts the
344        /// pre-Stage-2 singular `source:` map too (back-compat, [`de_sources`]). Default `[None]`
345        /// (anonymous — an "own" grant fails closed).
346        #[serde(
347            default = "default_sources",
348            alias = "source",
349            deserialize_with = "de_sources"
350        )]
351        sources: Vec<TenantSource>,
352        /// Which tenant-set reads may reach (default [`AccessMode::Own`]).
353        #[serde(default = "default_own")]
354        read: AccessMode,
355        /// Which tenant-set writes may reach (default [`AccessMode::Own`]).
356        #[serde(default = "default_own")]
357        write: AccessMode,
358    },
359    /// **Target** (R4/D8): this route/handler reads (and, with a `write` grant, writes) a SECOND
360    /// tenant `B`'s PUBLIC subset (never the caller's own). The non-federated (plain-wasm) analog of
361    /// a GraphQL `@tenant(scope: target)` field: the host resolves `B` from the first applicable
362    /// [`via`](Self::Target::via) source (5a: the routed domain) and binds a target scope BEFORE the
363    /// guest runs — confining every `orm` access to `tenant = B AND <public subset>` (deny-by-default
364    /// on an undeclared subset), and AST-rewriting every raw-`sql` READ the same way. A distinct
365    /// variant from [`Scoped`](Self::Scoped) so a route can't be both own and target (a misdeclaration
366    /// is unrepresentable). Gated by the operator's [`TenancySchema::target_eligible_fields`].
367    Target {
368        /// The prioritized target-source list (first-resolves-wins). 5a resolves only `domain`.
369        via: Vec<TargetSource>,
370        /// Names the host-held public subset (a table in [`TenancySchema::public_subsets`]) this
371        /// route's raw-`sql`/`orm` accesses confine to; the `orm` path confines every accessed table
372        /// on its own declared subset.
373        public: String,
374        /// **Target WRITE grant (5b), deny-by-default.** The SET-allowlist of columns a target write
375        /// (INSERT / UPDATE, via the typed `orm` only) may set. **Empty ⇒ read-only** (today's
376        /// behavior). Non-empty ⇒ the guest may INSERT/UPDATE rows in `B`'s public subset, setting
377        /// ONLY these columns — the host force-stamps `tenant = B` and the public-visibility columns,
378        /// confines an UPDATE's `WHERE` to `tenant = B AND <public>`, and refuses a DELETE, a raw-SQL
379        /// write, or any attempt to set the tenant/visibility columns (so a target write can never
380        /// change ownership or flip a row's visibility). The tenant/public columns MUST NOT appear in
381        /// this list.
382        #[serde(default, skip_serializing_if = "Vec::is_empty")]
383        write: Vec<String>,
384        /// **`target_or_null`** (the target-axis analog of [`AccessMode::OwnOrNull`]): when `true`, a
385        /// target READ confines to `(<tenant col> = B OR <tenant col> IS NULL) AND <public subset>` —
386        /// `B`'s public rows PLUS the shared `NULL`-tenant **base/reference** rows (a public funnel's
387        /// inheritance floor) — instead of `tenant = B` alone. Read-only: a target WRITE still stamps
388        /// `B`. The `NULL` disjunct is added ONLY on plain tenant (`Column`) tables, never a
389        /// `TenantOrSession` (its `NULL` partition is session rows) nor an `Unscoped` global.
390        #[serde(default, skip_serializing_if = "std::ops::Not::not")]
391        null_base: bool,
392    },
393}
394
395impl Tenancy {
396    /// Whether this decision enables in-site row scoping (own [`Scoped`](Self::Scoped) or
397    /// [`Target`](Self::Target)), vs. plain queries ([`Disabled`](Self::Disabled)).
398    pub fn is_scoped(&self) -> bool {
399        matches!(self, Self::Scoped { .. } | Self::Target { .. })
400    }
401
402    /// Whether this decision reads/writes a SECOND tenant (the target axis) rather than the caller's
403    /// own — the plain-wasm analog of a `@tenant(scope: target)` field.
404    pub fn is_target(&self) -> bool {
405        matches!(self, Self::Target { .. })
406    }
407
408    /// Whether this (own) decision names the R1 async-lane [`SignedContext`](TenantSource::SignedContext)
409    /// source — i.e. a **consumer** that resolves the originator's sealed tenant from a drained
410    /// message's `signed_context`. A consumer that declares it must have its bindings rebuilt PER
411    /// MESSAGE with that message's envelope (the site-`consumers` dispatch); a decision that does not
412    /// declare it resolves identically whether or not an envelope is present, so it uses the efficient
413    /// built-once binding. `false` for `Target`/`Disabled`.
414    pub fn declares_signed_context(&self) -> bool {
415        matches!(self, Self::Scoped { sources, .. } if sources.contains(&TenantSource::SignedContext))
416    }
417
418    /// Whether `self` (a **per-component** decision, e.g. a per-handler tenancy) stays **within**
419    /// `ceiling` (the **site-level** decision) — never widening the reachable tenant-set. Enforced
420    /// fail-closed at bind so a per-handler value can narrow within its site ceiling but not widen
421    /// it (`HandlersSiteConfig::tenancy`). Both are deploy-author config; this guards against an
422    /// accidental widening, and it is the sole security-relevant relationship because the operator
423    /// posture separately caps [`AccessMode::All`].
424    ///
425    /// Subset semantics (not a rank): `Disabled` is the **broadest** in-site policy (plain queries,
426    /// no tenant filter → reaches every row in the project db), so removing scoping under a scoped
427    /// ceiling is a widening. The `Target` axis is governed by the operator's separate
428    /// `target_eligible_fields` allowlist, so a target-vs-own axis mismatch across the ceiling is
429    /// refused (fail-closed) rather than silently reinterpreted.
430    pub fn narrows_within(&self, ceiling: &Self) -> bool {
431        use Tenancy::*;
432        match (self, ceiling) {
433            // A site doing no in-site scoping already reaches every row, so any per-handler
434            // decision is a narrowing-or-equal.
435            (_, Disabled) => true,
436            // Removing scoping under a scoped/target ceiling is a WIDENING — refuse.
437            (Disabled, _) => false,
438            // Same-axis in-site scoping: same tenant column, and each grant is a subset.
439            (
440                Scoped {
441                    column: c,
442                    read: r,
443                    write: w,
444                    ..
445                },
446                Scoped {
447                    column: cc,
448                    read: rc,
449                    write: wc,
450                    ..
451                },
452            ) => c == cc && r.within(*rc) && w.within(*wc),
453            // The target axis is operator-gated (`target_eligible_fields`); a target handler under a
454            // target ceiling is within.
455            (Target { .. }, Target { .. }) => true,
456            // Any own-vs-target axis mismatch across the ceiling is a misdeclaration — refuse.
457            _ => false,
458        }
459    }
460}
461
462/// How the host scopes one table under a project's [`TenancySchema`] (`PLAN-tenancy-principal`,
463/// Decision A / D2 / D3). A table's scope is a fact of the **data model**, declared per project (the
464/// guiding principle — the app configures its own concepts — not baked into a component). New
465/// variants (the R3 session disjunct, the R4 target public-subset) land in later stages; the enum is
466/// `#[non_exhaustive]` so adding them is not a breaking change for downstream crates.
467#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
468#[serde(tag = "kind", rename_all = "snake_case")]
469#[non_exhaustive]
470pub enum TableScope {
471    /// Scope on the schema's [`default_tenant_key`](TenancySchema::default_tenant_key) = the resolved
472    /// tenant (the common case).
473    Tenant,
474    /// The identity table (`tenant`/`org`/`account`), keyed by its own PK: scope on `key` = the
475    /// resolved tenant instead of the default column (R2). `key` MUST be unique — a non-unique key
476    /// would match other tenants' rows — validated at schema load.
477    TenantKeyed { key: String },
478    /// Global reference/enum data (`countries`): reads are unscoped (reachable even by a
479    /// principal-less request — fail-closed is per table-scope, not per invocation); writes are
480    /// deny-by-default (a shared-data write is a cross-tenant blast). Host-declared, never
481    /// guest-inferred (a guest can't mark a sensitive table global).
482    Unscoped,
483    /// An **anonymous-first** table (R3): rows are owned EITHER by a resolved tenant
484    /// (`default_tenant_key = <Tenant fact>`) OR by an anonymous session
485    /// ([`session_key`](TenancySchema::session_key)` = <Session fact>`, on `default_tenant_key IS
486    /// NULL` rows). A read lowers to the disjunction `Or([tenant_key = T, session_key = S])` over
487    /// whichever axis facts the request carries; the disjoint columns confine a cheap anon session
488    /// to `tenant IS NULL` rows structurally (never tenant-owned rows). Requires the schema to set
489    /// `session_key`; a `TenantOrSession` table with no `session_key` is refused (deny-by-default).
490    ///
491    /// The confinement rests on the invariant **a session-owned row has `default_tenant_key IS
492    /// NULL`** — the host write path enforces it (an anon write stamps only `session_key`, leaving
493    /// the tenant key NULL; `promote` is the sole cross-partition move, guarded by `tenant_key IS
494    /// NULL`). An app should add a DB `CHECK (<tenant_key> IS NULL OR <session_key> IS NULL)` as
495    /// belt-and-suspenders: a raw-SQL migration or an `all`-grant write that set BOTH columns on one
496    /// row would let a session reader match a row a tenant also owns.
497    TenantOrSession,
498    /// A **base-inclusive** table: rows with `default_tenant_key IS NULL` are **shared base** data
499    /// (global reference rows — e.g. base EP-vocabulary packs), readable by every tenant, while
500    /// non-NULL rows are the usual per-tenant rows. A READ confines to
501    /// `(default_tenant_key = <resolved own/target> OR default_tenant_key IS NULL)` (AND the public
502    /// subset on the target axis) on BOTH the own and target axes — the shared base is folded in
503    /// regardless of the *field's* scope, so a mixed-table field can fold the base into only the
504    /// tables that declare it (unlike the field-level `own_or_null`/`target_or_null`, which widen
505    /// every table the field reads). A WRITE stamps the resolved tenant exactly like [`Tenant`] — a
506    /// guest can never create or update a `NULL`-tenant base row (base rows are operator-seeded via a
507    /// privileged path). This is the per-**table** analog of the field-level NULL-base modes, and the
508    /// base-partition sibling of [`TenantOrSession`] (whose NULL partition is anonymous-session rows
509    /// instead of shared base). Unlike [`Unscoped`], the per-tenant (non-NULL) rows keep the
510    /// `tenant = <resolved>` boundary — only the NULL rows are shared — so a tenant can never read
511    /// another tenant's owned rows.
512    TenantOrBase,
513}
514
515/// The effective, host-resolved scope for one table (from [`TenancySchema::resolve`]) — the input
516/// the ORM scope-injector needs per table reference. `None` from `resolve` means **refused**
517/// (undeclared — deny-by-default); this enum is only the *declared* outcomes.
518#[derive(Debug, Clone, PartialEq, Eq)]
519pub enum ResolvedScope {
520    /// Scope this table on `column` = the resolved tenant (the injector picks the mode/value).
521    Column(String),
522    /// No tenant predicate — a globally-readable `Unscoped` table.
523    Unscoped,
524    /// The R3 anonymous-first disjunction: a read is `Or([tenant = <Tenant fact>, session =
525    /// <Session fact>])` over whichever axis facts are present; a write stamps the actor's own axis
526    /// (`tenant` if authenticated, else `session`, with the other column left `NULL`).
527    TenantOrSession {
528        /// The tenant column (`default_tenant_key`).
529        tenant: String,
530        /// The anonymous-session column (`session_key`).
531        session: String,
532    },
533    /// A base-inclusive table ([`TableScope::TenantOrBase`]): a READ confines to
534    /// `(tenant = <resolved> OR tenant IS NULL)` (the NULL rows are shared base, folded in on both
535    /// the own and target axes regardless of the field mode); a WRITE stamps `tenant = <resolved>`
536    /// exactly like [`Column`](ResolvedScope::Column) (never a NULL-base row).
537    TenantOrBase {
538        /// The tenant column (`default_tenant_key`); its `NULL` rows are the shared base.
539        tenant: String,
540    },
541}
542
543/// A project's tenant-isolation **schema map** — the host-held facts the scope-injector keys off
544/// (`PLAN-tenancy-principal`, D2). Per-project, not per-component: a table's tenant key is a fact of
545/// the data model. **Absent** (no project schema at all) ⇒ the legacy behavior (every table scopes
546/// on the component's `Tenancy::Scoped.column`, byte-identical to pre-schema). **Present** ⇒ the
547/// `tables` map is authoritative and **exhaustive**: a scoped component touching a table with no
548/// entry is *refused* (deny-by-default, D3 — "no key" and "forgot the key" are indistinguishable, so
549/// the safe collapse is deny; `Unscoped` is the explicit, reviewed "this table is global").
550#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
551#[serde(default, deny_unknown_fields)]
552pub struct TenancySchema {
553    /// The tenant column for a [`TableScope::Tenant`] table (e.g. `tenant_id`).
554    pub default_tenant_key: String,
555    /// The anonymous-session column for [`TableScope::TenantOrSession`] tables (e.g. `session_id`),
556    /// present iff the project uses the R3 session axis. A `TenantOrSession` table with no
557    /// `session_key` is refused (the disjunct is unrepresentable — deny-by-default).
558    #[serde(default, skip_serializing_if = "Option::is_none")]
559    pub session_key: Option<String>,
560    /// Per-table scope facts. Authoritative + exhaustive when a schema is present (an absent table
561    /// is refused, not defaulted — see the type doc).
562    pub tables: BTreeMap<String, TableScope>,
563    /// **R4 target axis — the operator's host-held allowlist ceiling.** The set of root
564    /// Query/Mutation field names (and plain-wasm route ids) that may carry `@tenant(scope: target)`
565    /// at all. Composition **refuses** a `target` field absent from this set — the app declares
566    /// intent in its SDL, but the operator gates which fields may cross to another tenant. Empty ⇒
567    /// no field may be target (deny-by-default).
568    #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
569    pub target_eligible_fields: BTreeSet<String>,
570    /// **R4 target axis — per-table PUBLIC subset definitions.** Keyed by table name: the host-held
571    /// visibility predicate + the deny-by-default `world_public`/`listable` flags a target read/write
572    /// confines to. A `target` field over a table with **no** entry here is refused at composition
573    /// (mandatory — deny-by-default); the ORM join composition refuses a joined table with no entry
574    /// under a target read (the strict analog of the missing-tenant-column fail-close).
575    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
576    pub public_subsets: BTreeMap<String, PublicSubset>,
577    /// **R4 target axis — the operator-curated `handle` registry (5c).** Maps a PUBLIC handle/slug →
578    /// the target tenant's context tag `B` (the same opaque value a routed domain resolves to). A
579    /// [`TargetSource::Handle`] resolves `B` ONLY for a slug listed here (deny-by-default: an unlisted
580    /// slug is indistinguishable from an absent one — no existence oracle, G4), and ONLY when the
581    /// route's `public` subset is [`world_public`](PublicSubset::world_public) (G2/G3). The handle
582    /// source is always READ-ONLY (G1). Empty ⇒ no slug is handle-addressable. This is the opt-in
583    /// allowlist that keeps handle addressing from reaching an arbitrary tenant — only tenants the
584    /// operator deliberately publishes a handle for are reachable.
585    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
586    pub handles: BTreeMap<String, String>,
587}
588
589impl Default for TenancySchema {
590    fn default() -> Self {
591        Self {
592            default_tenant_key: "tenant_id".to_string(),
593            session_key: None,
594            tables: BTreeMap::new(),
595            target_eligible_fields: BTreeSet::new(),
596            public_subsets: BTreeMap::new(),
597            handles: BTreeMap::new(),
598        }
599    }
600}
601
602impl TenancySchema {
603    /// A **deny-all** schema: present (so it is authoritative, not legacy `Uniform`) with **no**
604    /// declared tables, so every table is undeclared and every guest ORM query is refused
605    /// deny-by-default. The host binds this as the fail-closed posture when a project's stored
606    /// schema is present but cannot be read/parsed — never a silent downgrade to `Uniform`.
607    pub fn deny_all() -> Self {
608        Self {
609            default_tenant_key: "tenant_id".to_string(),
610            session_key: None,
611            tables: BTreeMap::new(),
612            // A deny-all posture must also refuse every target field + declare no public subset, so
613            // the target axis fails closed exactly like the own axis when a schema can't be read.
614            target_eligible_fields: BTreeSet::new(),
615            public_subsets: BTreeMap::new(),
616            // No handle resolves under deny-all (an unreadable schema exposes no public handle).
617            handles: BTreeMap::new(),
618        }
619    }
620
621    /// Whether a root field / route `field` is operator-permitted to carry `@tenant(scope: target)`
622    /// (the host-held allowlist ceiling). Composition refuses a `target` field for which this is
623    /// `false`, so the app's SDL intent can never exceed the operator's grant.
624    pub fn target_field_eligible(&self, field: &str) -> bool {
625        self.target_eligible_fields.contains(field)
626    }
627
628    /// Whether the named public `subset` is flagged [`world_public`](PublicSubset::world_public) —
629    /// the deny-by-default host flag that admits the anonymous `handle` source (G2/G3). A subset that
630    /// declares a public predicate but is NOT `world_public` is target-readable via
631    /// domain/capability, but NEVER via a handle.
632    pub fn subset_is_world_public(&self, subset: &str) -> bool {
633        self.public_subsets
634            .get(subset)
635            .is_some_and(|s| s.world_public)
636    }
637
638    /// Resolve a PUBLIC `handle`/slug to its target tenant's context tag `B` (5c), or `None` for an
639    /// unlisted slug (deny-by-default — an unlisted slug is indistinguishable from an absent one, G4).
640    /// Only slugs the operator deliberately published in [`handles`](Self::handles) resolve.
641    pub fn resolve_handle(&self, slug: &str) -> Option<&str> {
642        self.handles.get(slug).map(String::as_str)
643    }
644
645    /// The host-held PUBLIC subset for `table` (its visibility predicate + `world_public`/`listable`
646    /// flags), or `None` when the table declares none — a target read/write over which is refused
647    /// (deny-by-default), including a joined ref with no declared subset.
648    pub fn public_subset(&self, table: &str) -> Option<&PublicSubset> {
649        self.public_subsets.get(table)
650    }
651
652    /// Validate the schema before it is stored — the safety checks the target-read confinement
653    /// assumes. Returns a human-readable reason on the first violation.
654    ///
655    /// **An empty public predicate is refused (R4/D8).** A [`PublicSubset`] with no terms would
656    /// match **every** row (`tenant = B` with no visibility restriction), silently defeating the
657    /// target-read confinement and exposing a tenant's PRIVATE rows — so a subset that declares a
658    /// public surface must actually restrict it. Callers reject a schema that fails this rather than
659    /// store a match-all subset (the write path is the single choke point where this can be caught).
660    pub fn validate(&self) -> Result<(), String> {
661        for (table, subset) in &self.public_subsets {
662            if subset.predicate.terms.is_empty() {
663                return Err(format!(
664                    "public subset for table `{table}` has an empty predicate (would match every \
665                     row, defeating the target-read confinement) — declare at least one visibility \
666                     term (e.g. `published = true`)"
667                ));
668            }
669        }
670        Ok(())
671    }
672
673    /// Resolve how to scope `table`. `None` ⇒ **refused** (undeclared under a present schema —
674    /// deny-by-default; the injector fails the query closed; ALSO returned for a `TenantOrSession`
675    /// table when the schema declares no `session_key`, so the unrepresentable disjunct fails
676    /// closed). `Some(Column)` ⇒ scope on that column; `Some(Unscoped)` ⇒ global read; `Some(
677    /// TenantOrSession)` ⇒ the R3 disjunction.
678    pub fn resolve(&self, table: &str) -> Option<ResolvedScope> {
679        match self.tables.get(table)? {
680            TableScope::Tenant => Some(ResolvedScope::Column(self.default_tenant_key.clone())),
681            TableScope::TenantKeyed { key } => Some(ResolvedScope::Column(key.clone())),
682            TableScope::Unscoped => Some(ResolvedScope::Unscoped),
683            TableScope::TenantOrSession => Some(ResolvedScope::TenantOrSession {
684                tenant: self.default_tenant_key.clone(),
685                session: self.session_key.clone()?,
686            }),
687            TableScope::TenantOrBase => Some(ResolvedScope::TenantOrBase {
688                tenant: self.default_tenant_key.clone(),
689            }),
690        }
691    }
692
693    /// The `table → `[`ResolvedScope`] map the host threads into the ORM scope injector (wrapped as
694    /// `boatramp_core::orm::TableKeys::PerTable` one layer up — that type lives in the crate that owns
695    /// the injector, which depends on this one). The [`TableScope`] match is exhaustive **here**, in
696    /// its defining crate, so adding a variant is a compile error to classify rather than a silent
697    /// miss. A `TenantOrSession` table with no `session_key` is **omitted** — an absent entry is
698    /// refused (deny-by-default), never a silent single-axis scope. An empty schema yields an empty
699    /// map.
700    pub fn table_key_map(&self) -> BTreeMap<String, ResolvedScope> {
701        self.tables
702            .iter()
703            .filter_map(|(table, scope)| {
704                let resolved = match scope {
705                    TableScope::Tenant => ResolvedScope::Column(self.default_tenant_key.clone()),
706                    TableScope::TenantKeyed { key } => ResolvedScope::Column(key.clone()),
707                    TableScope::Unscoped => ResolvedScope::Unscoped,
708                    TableScope::TenantOrSession => ResolvedScope::TenantOrSession {
709                        tenant: self.default_tenant_key.clone(),
710                        session: self.session_key.clone()?, // no session_key ⇒ omit ⇒ deny
711                    },
712                    TableScope::TenantOrBase => ResolvedScope::TenantOrBase {
713                        tenant: self.default_tenant_key.clone(),
714                    },
715                };
716                Some((table.clone(), resolved))
717            })
718            .collect()
719    }
720}
721
722#[cfg(test)]
723mod tests {
724    use super::*;
725
726    #[test]
727    fn schema_resolves_per_table_key_and_denies_undeclared() {
728        // A present schema is authoritative + exhaustive: Tenant → default key, TenantKeyed → its
729        // own key (R2 identity table), Unscoped → global, and an ABSENT table is refused (D3).
730        let schema: TenancySchema = serde_json::from_str(
731            r#"{"default_tenant_key":"tenant_id","tables":{
732                 "orders":{"kind":"tenant"},
733                 "tenant":{"kind":"tenant_keyed","key":"id"},
734                 "countries":{"kind":"unscoped"}}}"#,
735        )
736        .unwrap();
737        assert_eq!(
738            schema.resolve("orders"),
739            Some(ResolvedScope::Column("tenant_id".into()))
740        );
741        assert_eq!(
742            schema.resolve("tenant"),
743            Some(ResolvedScope::Column("id".into())) // scoped on its PK, not tenant_id
744        );
745        assert_eq!(schema.resolve("countries"), Some(ResolvedScope::Unscoped));
746        assert_eq!(schema.resolve("secrets_table"), None); // undeclared → deny-by-default
747    }
748
749    #[test]
750    fn tenant_or_base_resolves_to_the_default_tenant_key_and_round_trips_json() {
751        // `{"kind":"tenant_or_base"}` → a base-inclusive table keyed on the default tenant column.
752        let schema: TenancySchema = serde_json::from_str(
753            r#"{"default_tenant_key":"tenant_id","tables":{"pack":{"kind":"tenant_or_base"}}}"#,
754        )
755        .unwrap();
756        assert_eq!(schema.tables.get("pack"), Some(&TableScope::TenantOrBase));
757        assert_eq!(
758            schema.resolve("pack"),
759            Some(ResolvedScope::TenantOrBase {
760                tenant: "tenant_id".into()
761            })
762        );
763        assert_eq!(
764            schema.table_key_map().get("pack"),
765            Some(&ResolvedScope::TenantOrBase {
766                tenant: "tenant_id".into()
767            })
768        );
769        // Serializes back with the snake_case tag.
770        assert!(serde_json::to_string(&TableScope::TenantOrBase)
771            .unwrap()
772            .contains("tenant_or_base"));
773    }
774
775    #[test]
776    fn schema_default_is_tenant_id_no_tables() {
777        let s = TenancySchema::default();
778        assert_eq!(s.default_tenant_key, "tenant_id");
779        assert!(s.tables.is_empty());
780        // With no declared tables, even the default column resolves nothing (present-but-empty
781        // schema refuses everything — a project adopting a schema declares its tables exhaustively).
782        assert_eq!(s.resolve("orders"), None);
783    }
784
785    #[test]
786    fn table_scope_roundtrips_through_json() {
787        for ts in [
788            TableScope::Tenant,
789            TableScope::TenantKeyed { key: "id".into() },
790            TableScope::Unscoped,
791            TableScope::TenantOrSession,
792        ] {
793            let j = serde_json::to_string(&ts).unwrap();
794            assert_eq!(ts, serde_json::from_str::<TableScope>(&j).unwrap());
795        }
796    }
797
798    #[test]
799    fn tenant_or_session_needs_a_session_key_else_denies() {
800        // With a session_key, a TenantOrSession table resolves to the R3 disjunct on both columns.
801        let s = TenancySchema {
802            default_tenant_key: "tenant_id".into(),
803            session_key: Some("session_id".into()),
804            tables: BTreeMap::from([("carts".into(), TableScope::TenantOrSession)]),
805            ..Default::default()
806        };
807        assert_eq!(
808            s.resolve("carts"),
809            Some(ResolvedScope::TenantOrSession {
810                tenant: "tenant_id".into(),
811                session: "session_id".into(),
812            })
813        );
814
815        // WITHOUT a session_key the disjunct is unrepresentable ⇒ fail closed: `resolve` refuses,
816        // and `table_key_map` OMITS it (an absent entry is deny-by-default at the injector), never a
817        // silent single-axis scope.
818        let s = TenancySchema {
819            default_tenant_key: "tenant_id".into(),
820            session_key: None,
821            tables: BTreeMap::from([("carts".into(), TableScope::TenantOrSession)]),
822            ..Default::default()
823        };
824        assert_eq!(s.resolve("carts"), None);
825        assert!(
826            !s.table_key_map().contains_key("carts"),
827            "a TenantOrSession table without a session_key must be omitted (denied), not scoped"
828        );
829    }
830
831    #[test]
832    fn scoped_defaults_are_own_own_none_source() {
833        // Only `column` is required; sources default to [None], both axes to Own.
834        let t: Tenancy = serde_json::from_str(r#"{"mode":"scoped","column":"tenant_id"}"#).unwrap();
835        assert_eq!(
836            t,
837            Tenancy::Scoped {
838                column: "tenant_id".into(),
839                sources: vec![TenantSource::None],
840                read: AccessMode::Own,
841                write: AccessMode::Own,
842            }
843        );
844        assert!(t.is_scoped());
845    }
846
847    #[test]
848    fn sources_accept_both_the_legacy_singular_and_the_stage2_list() {
849        // Back-compat: the pre-Stage-2 singular `source:` map still parses (→ a one-element list),
850        // so a v0.4.0 tenancy config resolves exactly as before.
851        let legacy: Tenancy = serde_json::from_str(
852            r#"{"mode":"scoped","column":"tenant_id","source":{"kind":"domain"}}"#,
853        )
854        .unwrap();
855        let Tenancy::Scoped { sources, .. } = &legacy else {
856            panic!("scoped")
857        };
858        assert_eq!(sources, &vec![TenantSource::Domain]);
859
860        // Stage 2: a priority-ordered list of per-trigger sources.
861        let listed: Tenancy = serde_json::from_str(
862            r#"{"mode":"scoped","column":"tenant_id","sources":[{"kind":"token"},{"kind":"domain"}]}"#,
863        )
864        .unwrap();
865        let Tenancy::Scoped { sources, .. } = &listed else {
866            panic!("scoped")
867        };
868        assert_eq!(
869            sources,
870            &vec![
871                TenantSource::Token {
872                    claim: "tid".into()
873                },
874                TenantSource::Domain
875            ]
876        );
877    }
878
879    #[test]
880    fn disabled_is_an_explicit_decision() {
881        let t: Tenancy = serde_json::from_str(r#"{"mode":"disabled"}"#).unwrap();
882        assert_eq!(t, Tenancy::Disabled);
883        assert!(!t.is_scoped());
884    }
885
886    #[test]
887    fn token_source_defaults_the_claim_to_tid() {
888        let t: Tenancy = serde_json::from_str(
889            r#"{"mode":"scoped","column":"tenant_id","source":{"kind":"token"},"read":"own_or_null","write":"own"}"#,
890        )
891        .unwrap();
892        let Tenancy::Scoped { sources, read, .. } = t else {
893            panic!("scoped")
894        };
895        assert_eq!(
896            sources,
897            vec![TenantSource::Token {
898                claim: "tid".into()
899            }]
900        );
901        assert_eq!(read, AccessMode::OwnOrNull);
902    }
903
904    #[test]
905    fn access_mode_cross_tenant_and_own_value_flags() {
906        assert!(AccessMode::All.is_cross_tenant());
907        assert!(!AccessMode::Own.is_cross_tenant());
908        assert!(AccessMode::Own.needs_own_value());
909        assert!(AccessMode::OwnOrNull.needs_own_value());
910        assert!(!AccessMode::Null.needs_own_value());
911        assert!(!AccessMode::All.needs_own_value());
912        assert!(!AccessMode::None.needs_own_value());
913    }
914
915    #[test]
916    fn roundtrips_through_json() {
917        let t = Tenancy::Scoped {
918            column: "org_id".into(),
919            sources: vec![TenantSource::Domain],
920            read: AccessMode::OwnOrNull,
921            write: AccessMode::Own,
922        };
923        let s = serde_json::to_string(&t).unwrap();
924        assert_eq!(t, serde_json::from_str::<Tenancy>(&s).unwrap());
925    }
926
927    #[test]
928    fn tenancy_class_default_is_own_and_target_flag() {
929        assert_eq!(TenancyClass::default(), TenancyClass::Own);
930        assert!(!TenancyClass::Own.is_target());
931        let tgt = TenancyClass::Target {
932            via: vec![TargetSource::Domain, TargetSource::Handle],
933            public: "storefront".into(),
934            write: vec![],
935            null_base: false,
936        };
937        assert!(tgt.is_target());
938        // Round-trips (the class rides on the composed supergraph).
939        assert_eq!(
940            tgt,
941            serde_json::from_str(&serde_json::to_string(&tgt).unwrap()).unwrap()
942        );
943    }
944
945    #[test]
946    fn target_schema_facts_roundtrip_and_gate_deny_by_default() {
947        let mut schema = TenancySchema {
948            default_tenant_key: "tenant_id".into(),
949            tables: BTreeMap::from([("products".into(), TableScope::Tenant)]),
950            ..Default::default()
951        };
952        schema
953            .target_eligible_fields
954            .insert("publicProducts".into());
955        schema.public_subsets.insert(
956            "products".into(),
957            PublicSubset {
958                predicate: PublicPredicate {
959                    terms: vec![
960                        PublicTerm::Cmp {
961                            column: "published".into(),
962                            op: PublicCmp::Eq,
963                            value: PublicLiteral::Bool(true),
964                        },
965                        PublicTerm::Null {
966                            column: "deleted_at".into(),
967                            negated: false,
968                        },
969                    ],
970                },
971                world_public: true,
972                listable: true,
973            },
974        );
975
976        // The operator allowlist gates which fields may be target (deny-by-default).
977        assert!(schema.target_field_eligible("publicProducts"));
978        assert!(!schema.target_field_eligible("secretOrders"));
979        // A declared public subset resolves; an undeclared table is None (⇒ refused downstream).
980        assert!(schema.public_subset("products").unwrap().world_public);
981        assert!(schema.public_subset("orders").is_none());
982
983        // The whole schema round-trips (it is stored/loaded as the project config).
984        let s = serde_json::to_string(&schema).unwrap();
985        assert_eq!(schema, serde_json::from_str::<TenancySchema>(&s).unwrap());
986    }
987
988    #[test]
989    fn validate_rejects_an_empty_public_predicate() {
990        let mut schema = TenancySchema::default();
991        // A public subset with at least one visibility term is valid.
992        schema.public_subsets.insert(
993            "products".into(),
994            PublicSubset {
995                predicate: PublicPredicate {
996                    terms: vec![PublicTerm::Cmp {
997                        column: "published".into(),
998                        op: PublicCmp::Eq,
999                        value: PublicLiteral::Bool(true),
1000                    }],
1001                },
1002                world_public: true,
1003                listable: true,
1004            },
1005        );
1006        assert!(schema.validate().is_ok());
1007        // An EMPTY predicate would match every row (`tenant = B` with no visibility restriction),
1008        // defeating the target-read confinement — refused at the write path.
1009        schema.public_subsets.insert(
1010            "orders".into(),
1011            PublicSubset {
1012                predicate: PublicPredicate { terms: vec![] },
1013                world_public: true,
1014                listable: false,
1015            },
1016        );
1017        let err = schema.validate().unwrap_err();
1018        assert!(
1019            err.contains("orders") && err.contains("empty predicate"),
1020            "got: {err}"
1021        );
1022    }
1023
1024    #[test]
1025    fn a_pre_stage5_schema_deserializes_with_empty_target_facts() {
1026        // A schema stored by a pre-Stage-5 binary has no target fields; `#[serde(default)]` must
1027        // fill them empty (no target eligibility, no public subsets) — a clean fail-closed default,
1028        // never a parse error under `deny_unknown_fields`.
1029        let legacy = r#"{"default_tenant_key":"tenant_id","tables":{"notes":{"kind":"tenant"}}}"#;
1030        let schema: TenancySchema = serde_json::from_str(legacy).unwrap();
1031        assert!(schema.target_eligible_fields.is_empty());
1032        assert!(schema.public_subsets.is_empty());
1033        assert!(!schema.target_field_eligible("anything"));
1034    }
1035
1036    #[test]
1037    fn access_mode_subset_lattice() {
1038        use AccessMode::*;
1039        // {} ⊆ anything.
1040        for c in [None, Null, Own, OwnOrNull, All] {
1041            assert!(None.within(c));
1042        }
1043        // Own and Null are disjoint — neither is within the other.
1044        assert!(!Own.within(Null));
1045        assert!(!Null.within(Own));
1046        // Own ⊆ {Own, OwnOrNull, All}; Null ⊆ {Null, OwnOrNull, All}.
1047        assert!(Own.within(Own) && Own.within(OwnOrNull) && Own.within(All));
1048        assert!(Null.within(Null) && Null.within(OwnOrNull) && Null.within(All));
1049        // OwnOrNull ⊆ {OwnOrNull, All} only; not within Own or Null.
1050        assert!(OwnOrNull.within(OwnOrNull) && OwnOrNull.within(All));
1051        assert!(!OwnOrNull.within(Own) && !OwnOrNull.within(Null));
1052        // All ⊆ All only.
1053        assert!(All.within(All));
1054        assert!(!All.within(OwnOrNull) && !All.within(Own));
1055    }
1056
1057    fn scoped(read: AccessMode, write: AccessMode) -> Tenancy {
1058        Tenancy::Scoped {
1059            column: "tenant_id".into(),
1060            sources: vec![TenantSource::Token {
1061                claim: "tid".into(),
1062            }],
1063            read,
1064            write,
1065        }
1066    }
1067
1068    #[test]
1069    fn tenancy_narrows_within_ceiling() {
1070        use AccessMode::*;
1071        // Scoped narrows a Scoped ceiling: same column, read/write are subsets.
1072        assert!(scoped(Own, Own).narrows_within(&scoped(All, All)));
1073        assert!(scoped(All, Own).narrows_within(&scoped(All, All)));
1074        assert!(scoped(All, All).narrows_within(&scoped(All, All)));
1075        // A broader read/write than the ceiling is a widening — refused.
1076        assert!(!scoped(All, Own).narrows_within(&scoped(Own, Own)));
1077        assert!(!scoped(Own, All).narrows_within(&scoped(Own, Own)));
1078        // A different tenant column is not a narrowing (fail-closed).
1079        assert!(!Tenancy::Scoped {
1080            column: "org_id".into(),
1081            sources: vec![TenantSource::None],
1082            read: Own,
1083            write: Own,
1084        }
1085        .narrows_within(&scoped(All, All)));
1086    }
1087
1088    #[test]
1089    fn tenancy_disabled_widening_is_refused() {
1090        use AccessMode::*;
1091        // A `Disabled` site (plain queries, no scoping) is the broadest — anything is within it.
1092        assert!(scoped(Own, Own).narrows_within(&Tenancy::Disabled));
1093        assert!(Tenancy::Disabled.narrows_within(&Tenancy::Disabled));
1094        // But `Disabled` under a scoped ceiling REMOVES scoping = widening = refused.
1095        assert!(!Tenancy::Disabled.narrows_within(&scoped(Own, Own)));
1096        // Own axis vs target axis across the ceiling is a misdeclaration — refused both ways.
1097        let target = Tenancy::Target {
1098            via: vec![TargetSource::Domain],
1099            public: "storefront".into(),
1100            write: vec![],
1101            null_base: false,
1102        };
1103        assert!(!target.narrows_within(&scoped(All, All)));
1104        assert!(!scoped(Own, Own).narrows_within(&target));
1105        // A target handler under a target ceiling is within (operator-gated separately).
1106        assert!(target.narrows_within(&target));
1107    }
1108
1109    #[test]
1110    fn declares_signed_context_detects_the_async_lane_source() {
1111        // A consumer declaring `signed_context` (alone or alongside another source) → true, so its
1112        // bindings are rebuilt per drained message; anything else → false (built-once path).
1113        let ctx = Tenancy::Scoped {
1114            column: "tenant_id".into(),
1115            sources: vec![TenantSource::SignedContext],
1116            read: AccessMode::Own,
1117            write: AccessMode::Own,
1118        };
1119        assert!(ctx.declares_signed_context());
1120        let mixed = Tenancy::Scoped {
1121            column: "tenant_id".into(),
1122            sources: vec![
1123                TenantSource::Token {
1124                    claim: "tid".into(),
1125                },
1126                TenantSource::SignedContext,
1127            ],
1128            read: AccessMode::Own,
1129            write: AccessMode::Own,
1130        };
1131        assert!(mixed.declares_signed_context());
1132        let no_ctx = Tenancy::Scoped {
1133            column: "tenant_id".into(),
1134            sources: vec![TenantSource::None],
1135            read: AccessMode::Null,
1136            write: AccessMode::Null,
1137        };
1138        assert!(!no_ctx.declares_signed_context());
1139        assert!(!Tenancy::Disabled.declares_signed_context());
1140    }
1141
1142    #[test]
1143    fn de_opt_tenancy_bridges_ron_and_json() {
1144        use serde::Deserialize;
1145        #[derive(Debug, Deserialize)]
1146        struct W {
1147            #[serde(default, deserialize_with = "de_opt_tenancy")]
1148            tenancy: Option<Tenancy>,
1149        }
1150        let ron_opts = ron::Options::default()
1151            .with_default_extension(ron::extensions::Extensions::IMPLICIT_SOME);
1152
1153        // Scoped, fully-quoted internally-tagged spelling — the canonical apply.cfg/project.cfg form.
1154        let scoped_ron: W = ron_opts
1155            .from_str(
1156                r#"(tenancy: (mode: "scoped", column: "tenant_id",
1157                    sources: [(kind: "token", claim: "tid"), (kind: "signed_context")],
1158                    read: "all", write: "own"))"#,
1159            )
1160            .expect("scoped RON parses via the bridge");
1161        // Byte-identical JSON the control plane stores, through the SAME bridge.
1162        let scoped_json: W = serde_json::from_str(
1163            r#"{"tenancy":{"mode":"scoped","column":"tenant_id",
1164                "sources":[{"kind":"token","claim":"tid"},{"kind":"signed_context"}],
1165                "read":"all","write":"own"}}"#,
1166        )
1167        .expect("scoped JSON parses via the bridge");
1168        assert_eq!(scoped_ron.tenancy, scoped_json.tenancy);
1169        match scoped_ron.tenancy.unwrap() {
1170            Tenancy::Scoped {
1171                column,
1172                sources,
1173                read,
1174                write,
1175            } => {
1176                assert_eq!(column, "tenant_id");
1177                assert_eq!(
1178                    sources,
1179                    vec![
1180                        TenantSource::Token {
1181                            claim: "tid".into()
1182                        },
1183                        TenantSource::SignedContext
1184                    ]
1185                );
1186                assert_eq!(read, AccessMode::All);
1187                assert_eq!(write, AccessMode::Own);
1188            }
1189            other => panic!("expected scoped, got {other:?}"),
1190        }
1191
1192        // Target variant (nested TargetSource enum + write allowlist) also bridges.
1193        let target_ron: W = ron_opts
1194            .from_str(
1195                r#"(tenancy: (mode: "target", via: ["domain"], public: "storefront",
1196                    write: ["status"]))"#,
1197            )
1198            .expect("target RON parses");
1199        match target_ron.tenancy.unwrap() {
1200            Tenancy::Target {
1201                via,
1202                public,
1203                write,
1204                null_base,
1205            } => {
1206                assert_eq!(via, vec![TargetSource::Domain]);
1207                assert_eq!(public, "storefront");
1208                assert_eq!(write, vec!["status".to_string()]);
1209                assert!(
1210                    !null_base,
1211                    "absent null_base defaults to false (plain target)"
1212                );
1213            }
1214            other => panic!("expected target, got {other:?}"),
1215        }
1216        // `target_or_null` (null_base: true) round-trips through the bridge from RON and JSON.
1217        let ton_ron: W = ron_opts
1218            .from_str(
1219                r#"(tenancy: (mode: "target", via: ["domain"], public: "vocab", null_base: true))"#,
1220            )
1221            .expect("target_or_null RON parses");
1222        let ton_json: W = serde_json::from_str(
1223            r#"{"tenancy":{"mode":"target","via":["domain"],"public":"vocab","null_base":true}}"#,
1224        )
1225        .expect("target_or_null JSON parses");
1226        assert_eq!(ton_ron.tenancy, ton_json.tenancy);
1227        assert!(
1228            matches!(
1229                ton_ron.tenancy,
1230                Some(Tenancy::Target {
1231                    null_base: true,
1232                    ..
1233                })
1234            ),
1235            "null_base survives the bridge"
1236        );
1237
1238        // Disabled + absent.
1239        let disabled: W = ron_opts
1240            .from_str(r#"(tenancy: (mode: "disabled"))"#)
1241            .expect("disabled RON parses");
1242        assert_eq!(disabled.tenancy, Some(Tenancy::Disabled));
1243        let absent: W = ron_opts.from_str(r#"()"#).expect("absent parses");
1244        assert_eq!(absent.tenancy, None);
1245    }
1246}