Making the missing org filter not compile with tenantDb
Build a scoped database client that uses TypeScript and Drizzle to make a forgotten tenant filter impossible to write, the core defense of multi-tenant data isolation.
One missing where and a customer sees another company’s invoice
Section titled “One missing where and a customer sees another company’s invoice”Picture a pull request that lands on a Tuesday afternoon. A teammate added a route that opens a single invoice, and the data fetch reads like this:
const [invoice] = await db.select().from(invoices).where(eq(invoices.id, id));You review it. The query selects from invoices, filters on the id, and takes the row. Nothing jumps out, so you approve it, it merges, and it ships.
Here is what you approved. The invoices table holds every invoice from every company that uses your product. This query asks for the invoice with this id and nothing more. It never adds “and only if it belongs to the org the current user is working inside.” So when a user pastes a URL with an id that isn’t theirs, or just increments the one they have, the database hands the row over, the route renders it, and a customer’s dashboard now displays a different company’s invoice: their amounts, their customer’s name, their line items.
This is the highest-severity bug class in a multi-tenant SaaS. One company seeing another company’s data is the failure that ends trials, breaks contracts, and triggers breach disclosures. The reason it is so dangerous is the same reason your review missed it: the defect is the absence of a line. There is no wrong code on the screen, only correct code with a filter missing, and a missing filter looks the same as nothing missing at all.
You already have everything you need to scope this query. In the previous lesson you built requireOrgUser(), which reads the validated session and hands back a trusted { user, orgId, role }, so the orgId is right there. The invoices table has an organizationId column on every row, which makes it a tenant-owned table . The fix is one predicate:
const [invoice] = await db .select() .from(invoices) .where(eq(invoices.id, id));Passes review every time. It selects the invoice by id and renders it. Nothing on this line says “defect,” because there is no wrong code here, only a filter that isn’t here. To catch it, the reviewer would have to check for the absence of an org predicate on every read, on every PR, forever.
const { orgId } = await requireOrgUser();const [invoice] = await db .select() .from(invoices) .where(and(eq(invoices.organizationId, orgId), eq(invoices.id, id)));Correct, and that’s the trap. This version is right: pin the org, then the id, and the query can only ever return this org’s invoice. But the correctness is optional. The next person to write a fetch-by-id has to remember the and(eq(organizationId, ...)) every single time, and the day they don’t, the code still compiles, still passes review, and still ships.
Read those two tabs again. The manual fix is not wrong: it works, it’s airtight, and it’s what a careful developer writes. The point is not that manual scoping is a mistake. The point is that optional correctness is the bug. A defense that depends on every developer remembering it on every query is a defense that has already failed; you just don’t yet know which PR will reveal it.
By the end of this lesson, the unscoped read won’t compile.
Defense is a call shape, not more diligence
Section titled “Defense is a call shape, not more diligence”So how do you defend against a bug whose signature is the absence of a line?
The instinct is to add more review, more discipline, a checklist. That instinct is wrong, and seeing why is the turn the whole lesson rests on. Reviewers habituate. The missing filter gets caught on the first PR, because everyone is paying attention to the new tenancy rule. It slips through on the fortieth, when it’s buried in a diff that also touches three components and a test, and the reviewer’s eye slides over a fetch-by-id that looks like every other fetch-by-id they’ve approved. You cannot pay enough attention, forever, to reliably catch nothing being there. Vigilance is not a control.
The control is structural. You change the shape of the call so the scoped query is the only one you can write, and writing the unscoped one means reaching for a visibly different, obviously more dangerous tool. The protection isn’t that someone notices the missing filter. It’s that the call which omits the filter can no longer be written through the normal path.
Hold onto this distinction. It is the central idea of the lesson and the easiest one to garble:
The way you get there is to put two clients in the codebase and make them look different at the import line:
dbis the raw Drizzle client. It is unscoped and can read and write any org’s rows. It exists for admin tools, migrations, and scripts that legitimately operate across all organizations. You can spot it on sight, because it’s imported from@/db.tenantDb(orgId)is a thin wrapper arounddb, and the only client a request handler ever reaches for. Every call it exposes is org-scoped by construction, so there is no way to ask it for another org’s row.
A request handler that wants tenant data calls tenantDb(orgId). If a tenant read goes through bare db instead, you don’t catch it by inspecting its where clause; you catch it because the wrong client is imported. That’s a one-line signal, the same on every PR, the kind a linter flags and a reviewer’s eye snags on without having to think.
Here is a preview of the two shapes, before you build the wrapper:
db.query.invoices.findMany({ where: eq(invoices.status, 'open') }); // unscopedtenantDb(orgId).query.invoices.findMany({ where: eq(invoices.status, 'open') }); // scopedThis course has a standing rule, Architectural Principle #5: consume libraries directly. Don’t wrap Drizzle in your own query layer, and don’t hide Better Auth behind a homegrown facade. Thin wrappers rot, leak, and obscure the real API the team needs to learn. So building a wrapper around Drizzle should give you pause.
tenantDb is a deliberate, named exception to that rule, and naming it is what keeps it honest, because an unnamed exception is how a “consume libraries directly” codebase quietly grows a dozen accidental abstractions. The carve-out has a precise boundary: wrap the tenant read and write path, and leave the raw db as an escape hatch for legitimate cross-org work. Direct Drizzle stays correct for admin scripts and migrations, and tenantDb is mandatory for any request-handled read or write on a tenant table. That’s the entire rule.
The helper: wrap the query API, inject the predicate
Section titled “The helper: wrap the query API, inject the predicate”We’ll build it in two passes to keep things manageable. First we make it correct at runtime: the helper injects the org filter, the bug is gone, and the call site works. Then, in a later section, we layer the type system on top, so that skipping the scope doesn’t just fail at runtime, it fails to compile. Runtime first, types second, and don’t let the second pass leak into the first.
Start where you always should, at the call site you’re aiming for:
const db = tenantDb(orgId);const rows = await db.query.invoices.findMany({ where: eq(invoices.status, 'open'),});Notice what’s not there: no organizationId anywhere in the caller’s code. You ask for open invoices, and the org scope is somebody else’s job now. That’s the win. The predicate you used to have to remember is now structural, supplied by the wrapper, and impossible to leave out because you’re no longer the one writing it.
Here’s the wrapper that makes that call site work. It wraps the Drizzle relational query API you already know, the db.query.<table>.findMany({ where, with }) surface, for one table to start:
import 'server-only';import { and, eq } from 'drizzle-orm';import type { SQL } from 'drizzle-orm';import { db } from '@/db';import { invoices } from '@/db/schema';
export const tenantDb = (orgId: string) => ({ query: { invoices: { findMany: (config?: { where?: SQL }) => db.query.invoices.findMany({ ...config, where: and(eq(invoices.organizationId, orgId), config?.where), }), }, },});The factory signature. tenantDb takes an orgId and returns a plain object. This isn’t a re-implementation of Drizzle; it’s a small object that exposes only the slice of Drizzle the app actually uses, with the org scope baked in. Closing over orgId is the whole trick: every method the object exposes already knows the org.
import 'server-only';import { and, eq } from 'drizzle-orm';import type { SQL } from 'drizzle-orm';import { db } from '@/db';import { invoices } from '@/db/schema';
export const tenantDb = (orgId: string) => ({ query: { invoices: { findMany: (config?: { where?: SQL }) => db.query.invoices.findMany({ ...config, where: and(eq(invoices.organizationId, orgId), config?.where), }), }, },});The query.invoices surface mirrors the real client’s shape on purpose, so the call site reads almost identically to raw Drizzle. The only thing that changed is who supplies the org filter.
import 'server-only';import { and, eq } from 'drizzle-orm';import type { SQL } from 'drizzle-orm';import { db } from '@/db';import { invoices } from '@/db/schema';
export const tenantDb = (orgId: string) => ({ query: { invoices: { findMany: (config?: { where?: SQL }) => db.query.invoices.findMany({ ...config, where: and(eq(invoices.organizationId, orgId), config?.where), }), }, },});The composition, and the heart of the helper. Spread the caller’s config through unchanged, then override where with and(eq(invoices.organizationId, orgId), config?.where). The org predicate always comes first, and the caller’s where rides as the second argument.
import 'server-only';import { and, eq } from 'drizzle-orm';import type { SQL } from 'drizzle-orm';import { db } from '@/db';import { invoices } from '@/db/schema';
export const tenantDb = (orgId: string) => ({ query: { invoices: { findMany: (config?: { where?: SQL }) => db.query.invoices.findMany({ ...config, where: and(eq(invoices.organizationId, orgId), config?.where), }), }, },});The undefined case. When the caller passes no where at all, config?.where is undefined, and Drizzle’s and(predicate, undefined) simply drops it, leaving the org predicate alone. So tenantDb(orgId).query.invoices.findMany() with no arguments returns this org’s invoices, never everyone’s. The and is safe with a hole in it, but we still write it explicitly so the intent is on the page.
A few conventions are doing quiet work in that file, and they’re worth calling out. The very first line is import 'server-only'. This module imports the database client, and that import marks the whole file as server-only, so a stray import from a Client Component becomes a build error instead of a leaked connection string. The factory is an arrow function bound to const and exported by name, the house default. It also carries an explicit return type in the real codebase, both because exported functions get annotated and because here the signature is the point: the type is what does the enforcing, so it shouldn’t be left to inference.
Now the part that proves the design out: it holds up even against someone actively trying to slip past it. Suppose a developer writes a where designed to escape the scope:
tenantDb(orgId).query.invoices.findMany({ where: or(eq(invoices.organizationId, otherOrgId), eq(invoices.status, 'open')),});They’ve asked for rows belonging to some other org, or rows that are open. Surely that leaks the other org’s rows? It doesn’t. The wrapper takes their entire or(...) and pins it under the org and:
and(eq(invoices.organizationId, orgId), or(eq(invoices.organizationId, otherOrgId), ...))The outer and requires organizationId to equal this org on every returned row, no exceptions. So the eq(organizationId, otherOrgId) branch can only match a row that belongs to this org and the other org at once, which is a contradiction, so it matches zero rows. The scope holds against accidental omission and against a curious developer poking at it. The org and is on the outside, and nothing the caller writes on the inside can climb out of it.
One more thing to take in about the call site: the type of what you get back.
const scoped = tenantDb(orgId);const rows = await scoped.query.invoices.findMany({ where: eq(invoices.status, 'open') });That’s the runtime behavior done. The filter is injected, the unscoped result is unreachable through this client, and the call site barely changed from raw Drizzle. What it doesn’t yet do is turn a nonsensical call, scoping a table that has no org, into a compile error. We’ll get to that in the section after writes.
Writes: inject on insert, predicate on update and delete
Section titled “Writes: inject on insert, predicate on update and delete”Reads were the dangerous half, but a wrapper that scopes reads and leaves writes raw is a wrapper with a hole in it. A missing org filter on an update is just as bad as on a select, and worse, because it mutates. So tenantDb wraps the three write shapes too, each in its own small way.
These aren’t a new mechanism. insert, update, and delete (and the select().from(...) builder, if you reach for it) are just more methods hung off the same factory object, each closing over orgId exactly the way the query surface above does. We show only the query surface in full, because the pattern is identical; below we focus on what each write injects, not on re-deriving the wrapping you’ve already seen.
insert owns the organizationId column. Every row the caller inserts gets organizationId: orgId stamped on automatically:
await db.insert(invoices).values({ customerId, organizationId: orgId, amountCents, status: 'open',});The caller has to remember the column. Forget the highlighted organizationId line and one of two things happens: the insert fails on a not-null constraint if you’re lucky, or it writes an orphan row if the column is nullable. Either way, getting the org onto the row is the caller’s responsibility, on every insert.
await tenantDb(orgId).insert(invoices).values({ customerId, amountCents, status: 'open',});The helper owns the column. The organizationId line is gone from the caller, because the wrapper stamps it on for you. The caller can’t forget a column it doesn’t write, and the insert can’t land in the wrong org, because the only org it can carry is the one the wrapper closed over. If the caller does pass an organizationId and it disagrees with orgId, the helper throws: a mismatch there is a programmer bug, not a user error.
That last point is a small but deliberate design call. If a caller passes an organizationId that contradicts the wrapper’s orgId, the helper doesn’t quietly pick one; it throws. The course’s error rule is to return expected failures (a Result) and throw on impossible ones, and a mismatched org on a scoped insert is impossible by construction: it can only happen if someone wired the call up wrong. So it throws, loudly, in development, where the bug belongs.
update and delete can’t inject a column, because they target existing rows, so instead they always and-in the org predicate, exactly like reads. A scoped update is where(and(eq(organizationId, orgId), callerWhere)). The consequence is the one you want: an update aimed at another org’s row matches zero rows and changes nothing. Compare the two shapes directly. The raw, unscoped delete:
await db.delete(invoices).where(eq(invoices.id, id));happily removes whatever invoice has that id, whoever owns it. The scoped delete:
await tenantDb(orgId).delete(invoices).where(eq(invoices.id, id));removes that invoice only if it belongs to this org, and otherwise does nothing at all. The difference between “deleted the wrong company’s invoice” and “deleted nothing” is one outer and, and with the wrapper it’s not yours to forget.
There’s a trap hiding in the urge to make this convenient. At some point someone will want tenantDb to expose the raw client “just for this one tricky query,” through a .raw escape or a passthrough method. Don’t add it. The moment the wrapper hands back the unscoped client, the wrapper is decorative, and you’ve reintroduced the exact unscoped call shape you built it to remove. The only way to reach unscoped Drizzle stays the separately imported db, because that import is the visible, reviewable signal. A bypass tucked inside tenantDb is invisible.
Some tables have no org: the table registry
Section titled “Some tables have no org: the table registry”Now the second pass: turning the runtime backstop into a compile-time guarantee. This is the densest idea in the lesson, so we build it the way we built the helper, a simple version first and then the real one on top.
Start with the problem concretely. Not every table is tenant-owned. Better Auth’s user and verification tables are global: a user account isn’t owned by an organization, and it can belong to several. Those tables have no organizationId column at all. So this call:
tenantDb(orgId).query.user.findMany({ where: ... });is nonsense. There is nothing to scope by, since user has no org column for the wrapper to filter on. We don’t want this call to misbehave at runtime, or worse, silently return every user in the database. We want it to be a type error, so it never compiles in the first place.
Pass one, the runtime backstop. Keep a set of the table names that are genuinely tenant-owned, and have the helper refuse anything not in it:
const TENANT_TABLES = ['invoices', 'customers', 'member'] as const;If a call reaches a table through this helper that isn’t in TENANT_TABLES, the helper throws. That’s the floor: loud and immediate, but still a runtime failure. It fires when the code runs, which might be in a test, or might be in production. We want better.
Pass two, the type-level guarantee, which is the real win. Use that same set, but feed it to the type system so only the tenant tables are even reachable on the wrapper’s query surface. The shape is a mapped type: take the union of tenant-table names, and build the query object’s type by mapping over only those keys.
const TENANT_TABLES = ['invoices', 'customers', 'member'] as const;
type TenantTable = (typeof TENANT_TABLES)[number];
type TenantQuery = { [K in TenantTable]: { findMany: (config?: { where?: SQL }) => Promise<unknown[]>; };};
export const tenantDb = (orgId: string): { query: TenantQuery } => { // ...injects and(eq(table.organizationId, orgId), config?.where) per table};TENANT_TABLES is the single source of truth, frozen with as const so its values stay literal. TenantTable lifts those literals into a union type: 'invoices' | 'customers' | 'member'. One list, used twice: once at runtime as the backstop, once at the type level as the guarantee.
const TENANT_TABLES = ['invoices', 'customers', 'member'] as const;
type TenantTable = (typeof TENANT_TABLES)[number];
type TenantQuery = { [K in TenantTable]: { findMany: (config?: { where?: SQL }) => Promise<unknown[]>; };};
export const tenantDb = (orgId: string): { query: TenantQuery } => { // ...injects and(eq(table.organizationId, orgId), config?.where) per table};TenantQuery is a mapped type. [K in TenantTable] says: for each name in the union, and only those names, give the query object a key with a scoped findMany. There is deliberately no user key and no verification key, because those names aren’t in the union.
const TENANT_TABLES = ['invoices', 'customers', 'member'] as const;
type TenantTable = (typeof TENANT_TABLES)[number];
type TenantQuery = { [K in TenantTable]: { findMany: (config?: { where?: SQL }) => Promise<unknown[]>; };};
export const tenantDb = (orgId: string): { query: TenantQuery } => { // ...injects and(eq(table.organizationId, orgId), config?.where) per table};The return type pins the wrapper’s surface to exactly TenantQuery. This single annotation is what does the enforcing: tenantDb(orgId).query.user now accesses a key that the type says doesn’t exist, so TypeScript rejects it before the code ever runs. The runtime backstop from pass one is still there underneath, but for any table in the registry the type system catches the mistake first, at author time, as a red squiggle in the editor.
The mechanism, plainly: the registry is a list of table names, the type system reads that list, and the wrapper’s query surface gets exactly those keys and no others. Ask it for a table that isn’t tenant-owned and you’re reaching for a property that, as far as TypeScript is concerned, was never there. The missing-scope bug isn’t caught by a reviewer or even by a test run; it’s caught by your editor underlining the line in red as you type it.
The flow of that guarantee, in one picture:
Maps the registry into the query surface — one key per registered table, and no others.
A quick vocabulary note, since the word is overloaded: registry here means exactly this, one authoritative list the types are derived from. When you add a new tenant-owned table to the schema, you add its name to TENANT_TABLES, and the wrapper’s surface grows to include it. Forget to, and the wrapper simply won’t let you query the new table through it, which is a far better failure than scoping it wrong.
Now make that compile error something you feel rather than just read about. In the exercise below, the registry is missing a table, and a call to it is marked as one that should error. Your job is to make the type checker quiet:
`documents` is a tenant-owned table, but it's missing from the registry — so `tenantDb(orgId).query.documents` doesn't exist as a type, and the `@ts-expect-error` on the `user` line is reported as 'unused' because the registry isn't wired up yet. Add `'documents'` to `TENANT_TABLES` so the scoped surface includes it. The `user` call below must stay a type error — `user` is global, not tenant-owned, so it must remain unreachable.
- Fix all errors
The canonical action: requireOrgUser, then tenantDb
Section titled “The canonical action: requireOrgUser, then tenantDb”You now have a scoped client. The last move is to make reaching for it automatic, fixing the shape of a tenant-scoped Server Action so firmly that the unscoped version looks wrong on sight. Here’s the opener that should head every action that touches tenant data:
const { user, orgId } = await requireOrgUser();const db = tenantDb(orgId);const rows = await db.query.invoices.findMany({ where: eq(invoices.status, 'open') });Three lines. The first validates the session and produces a trusted orgId. The second narrows the client to that org. The third reads, with no manual filter and no place to forget one. There is no fourth line where the scope could go missing, because the scope was decided on line two.
That second line is load-bearing in a way worth dwelling on. orgId comes from requireOrgUser(), and only from there: not from a route param, not from a hidden form field, not from a request header, not from anything the client can set. This is the security invariant the whole pattern rests on:
Map this onto the five-seam Server Action shape you already know: parse, authorize, mutate, revalidate, return. You don’t need a new mental model here, only two familiar pieces to slot into it. requireOrgUser() is the authorize seam, and tenantDb(orgId) is what keeps mutate (and every read) scoped. The five seams haven’t changed; tenancy just made two of them concrete.
This is where the “call shape, not diligence” argument pays off at the point of use. When you review a PR with this pattern, you don’t audit every where clause for a missing org predicate. You check one thing: does the handler reach for tenantDb, or does it touch bare db? A tenant read through raw db in a request handler is wrong by its shape, the wrong client, visible at the import. That single mechanical check replaces an impossible vigilance.
Try it. The PR below has a few things wrong with it, and they’re the exact bugs this lesson exists to prevent. Leave a review comment on each line you’d flag:
Review this Server Action the way you'd review a teammate's PR. Three things here are exactly the bugs the tenantDb pattern exists to stop — leave a comment on each line you'd flag. Click any line to leave a review comment, then press Submit review.
'use server';
export async function listOpenInvoices(searchParams: { orgId: string }) { const orgId = searchParams.orgId; const open = await db.query.invoices.findMany({ where: eq(invoices.status, 'open'), });
const customers = await tenantDb(orgId).query.customers.findMany();
return { open, customers };}orgId comes straight off searchParams — request input the client controls. Even a perfectly-scoped query is wrong if it’s scoped to an org the user has no right to: hand tenantDb an attacker-supplied orgId and you’ve built an airtight read against someone else’s data. The orgId must come only from requireOrgUser(), which validates the session and the membership. That’s the one provenance this whole pattern rests on.
The fix:
const { orgId } = await requireOrgUser();This is the missing-where bug in its native habitat. db.query.invoices.findMany({ where: eq(invoices.status, 'open') }) asks for every org’s open invoices — there is no org predicate. The fix is not “add and(eq(invoices.organizationId, orgId), …)”; the fix is the call shape: route it through tenantDb, which can’t return another org’s row by construction.
const open = await tenantDb(orgId).query.invoices.findMany({ where: eq(invoices.status, 'open'),});One read through tenantDb and one through raw db in the same handler is a tell: the scoping is ad hoc, applied where someone happened to remember it. (And even this scoped read is pinned to the untrusted orgId from line 4.) The canonical shape derives orgId once from requireOrgUser() and reaches for tenantDb for every tenant read in the handler — no mix, nothing to forget.
The skill being rehearsed is recognizing the shape of the bug in a diff, not reasoning about each where. You don’t audit the predicate — you check two things: is the client tenantDb or bare db, and does the orgId come from requireOrgUser() or from request input? Bare db on a tenant table, or an orgId sourced from anything other than the session, is wrong on sight.
The correct version of this action threads a session-derived orgId through tenantDb for both reads:
'use server';
export async function listOpenInvoices() { const { orgId } = await requireOrgUser(); const db = tenantDb(orgId);
const open = await db.query.invoices.findMany({ where: eq(invoices.status, 'open'), }); const customers = await db.query.customers.findMany();
return { open, customers };}When you really do need to read across orgs
Section titled “When you really do need to read across orgs”Everything so far has pushed in one direction: tenant reads go through tenantDb, scoped, no exceptions. But genuine cross-org reads do exist: an internal admin dashboard that triages support tickets across every customer, a nightly job that rolls up revenue for all organizations, a BI export. These are real and legitimate, and they cannot go through tenantDb, because they’re defined by not being scoped to one org.
The call here is the one foreshadowed at the start: those reads reach for the unwrapped db, and they live somewhere visible. A tenant-scoped read helper belongs under db/queries/; a cross-org admin read belongs in a dedicated location, an admin/ or scripts/ directory, where the raw-db import is exactly the loud, deliberate signal you want. Different client, different file. Anyone reviewing code in that directory knows they’re in cross-org territory and reads accordingly, and anyone seeing a raw-db import outside it knows something’s off.
Here is the design decision that ties it together:
It’s worth knowing what the ecosystem reaches for instead, so you can recognize it and choose not to. There are ORM forks, runtime proxies, and AST-rewriting plugins that auto-inject the org filter via reflection, such as Prisma’s client extensions and various Drizzle proxy tricks. They promise you never have to think about scope again. The 2026 call in this stack is the opposite: an explicit wrapper, roughly forty lines, where the type signature is the documentation, the failure mode is loud (a misshapen call throws or won’t compile), and the raw client stays one import away for the legitimate reach. You reach for heavier machinery only past a table count this wrapper can’t comfortably track by hand, a threshold a first-year SaaS rarely hits. Explicit and boring beats clever and magic when the thing at stake is one company seeing another’s data.
One last thing: the name. This pattern shows up in the wild as forTenant, dbFor, orgDb, tenantScoped, and a dozen others. Pick one and grep for it. The day you find a second name in the codebase is the day the team started drifting and two people are about to build the same thing twice. This course uses tenantDb.
Quick recall
Section titled “Quick recall”To make the two-clients decision automatic, sort each scenario into the client it should use:
Sort each task into the client it should use — the scoped `tenantDb(orgId)` for request-handled tenant data, or the raw `db` (in a separate admin/script file) for legitimate cross-org work. Drag each item into the bucket it belongs to, then press Check.
If those came easily, the pattern has landed. The worst bug class in multi-tenant SaaS is a tenant read that forgot its org filter, and the fix isn’t to remember harder; it’s to make the forgetting fail to compile. Two clients in the codebase, one scoped by construction and one raw and visible, and an orgId that only ever comes from a validated session.
The next lesson goes one layer down, to the database itself, and to the one table whose data is high-stakes enough to deserve a second wall, Postgres Row-Level Security, even after tenantDb already guards the app layer.
External resources
Section titled “External resources”The query.<table>.findMany API the helper wraps, including the where and with options.
and / eq / or and the rest of the predicate builders the helper composes.
The community wrestling with the exact problem this lesson solves — wrappers, proxies, lint rules, and RLS compared.
The official reference for the [K in TenantTable] syntax that derives the scoped query surface, including key remapping.