← Roadmap

Concept explainer · R46 · theme: runtime-connection

Multi-tenant fan-out

What to do when there is no tenant-index to route by: query every tenant the user can see and union the results. The deliberate, marked fallback to operation-divined routing. Read this before the R46 plan.

R46 is still Backlog: this page explains the shape and the one resolved decision (the fan-out domain), but the marker syntax, result semantics, and parallelism story are open questions the Spec pass will settle. The plan is where those land.
The one-sentence version When no tenant-index table can name which tenant holds a field's data, the only correct answer to "give me all of it" is to open every tenant database the requesting user has a role in, run the field against each, and union what comes back; R46 makes that an explicit, marked arm of R45's tenant-binding model rather than something consumers hand-roll.

The problem, concretely

Two production patterns need the same shape:

R45: an index narrows the tenant

The index row names the tenant, so each child is routed to exactly one database. Precise: one query per tenant that actually holds data for this parent.

R46: no index, so fan out

Nothing narrows the set, so you query every tenant in the user's reach and union. Broad by necessity: the model cannot say where the data is, only where it could be.

Why fan-out must be asked for, never inferred

This is the hinge that connects R46 to R45. R45's whole posture is inference: it divines the tenant from column bindings the schema already carries. But fan-out is not a fact you can divine; "look everywhere" is a decision, not a binding. So R45 draws a hard line: a tenant-scoped field with no binding in scope is a build rejection (noTenantBinding), never a silent default connection, because routing tenant data through the default source because nothing named the tenant is exactly the cross-tenant leak the design exists to prevent.

R46 is the positive arm that resolves that rejection: an explicit schema marker classifies the field into a fan-out arm, and the arm ships together with its emitters here. That ordering is deliberate; R45's rejection keeps guarding every unmarked unroutable field, so an unfinished R46 can never quietly turn into a wrong-database read.

Unmarked and unroutable → rejected

R45 fails the build, naming the field. No guessing.

Marked → fan-out arm (R46)

The author positively opts in. The field classifies into the fan-out arm, which owns its emitters. Never a flag that suppresses the rejection.

Quiz Why can't R45 just infer fan-out for a tenant-scoped field that has no binding, instead of rejecting it?

Inference divines what the schema already encodes. Fan-out encodes nothing: an unroutable field is far more often a schema mistake than a deliberate "search everywhere," so defaulting to fan-out would mask errors and silently multiply query cost. The author must state the intent with a marker; absent that, the field stays rejected.

The fan-out domain: not the whole map, not the raw claims

The one decision the item has already resolved is which tenants a fanned-out field actually queries. It is the intersection of two sets: the tenant keys present in the deployment's Map<TenantId, DataSource>, and the tenantIds the requesting user holds a role for in the request's claims. Neither set alone is ever the domain. And the two directions of the difference are handled oppositely, which is the part worth slowing down on:

Mapped tenant, user has no role → skip silently

The database exists in the map, but the user holds no role there. Never queried. This is the authorization pre-filter, silent by design; the user simply cannot see tenants they have no claim to.

Claimed tenant, missing from map → request error

The user's claims name a tenant this subgraph does not host. This is a request-level error before any SQL runs, not a silent skip. The derived tenant set is the model's statement that data could exist there, so skipping it would present incomplete results as complete.

R45 already gives the same event, a divined tenant with no DataSource, the same error semantics, so the two items stay consistent. Deployments where a user's claims legitimately span more tenants than this particular subgraph hosts narrow the set in the claims-extraction seam, where the narrowing is the consumer's explicit statement rather than a silent runtime drop.

Quiz A user's claims list tenant 42, but 42 is not in this deployment's DataSource map. Fan-out over a marked field: what happens?

Note the asymmetry with the other direction: a mapped tenant the user has no role for is skipped silently (authorization). But a claimed tenant absent from the map is an error, because the derived set is a positive statement that the user's data could live there. Silently dropping it would understate the answer. If claims legitimately over-reach this subgraph, the fix is to narrow the derived set explicitly in the claims-extraction seam, not to swallow the mismatch at query time.

Mechanics ride the shipped substrate

Fan-out does not reimplement connection handling; it rides R429's acquisition seam (shipped, in the changelog). For each tenant in the domain, graphitron acquires through the map, runs one read-only transaction per tenant (R429's demarcation rule already covers N transactions per operation), and sets session state per acquisition so per-tenant row-level security composes: a tenant where the user has no row access simply contributes nothing. Results union; nulls and empties drop. The connection threading is R429's seam, not hand-rolled executor code inside generated fetchers, which is exactly the plumbing the hand-written megVedLarested resolver does by hand today.

Quiz The fan-out domain includes a tenant the user has a role in, but in that tenant's database RLS grants them access to zero rows. What does the union get from it?

Role-in-tenant (which decides the domain) and row-access-within-tenant (which RLS decides per query) are different layers. The user legitimately belongs to the tenant, so it is queried; RLS then scopes the rows, and zero rows is a valid empty answer, not a mismatch. That composes cleanly because session state is set per acquisition. Contrast the unmapped-claim case, which is an error precisely because the tenant was never queryable at all.

What the Spec pass still has to settle

The domain is resolved; several things are not, and they are why R46 is still Backlog rather than Spec:

Where it stands (as of 2026-07-17)