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.
The problem, concretely
Two production patterns need the same shape:
- No index narrows the tenant. A student's results live in per-university databases. When a tenant-index table exists, R45 routes each child row to its named tenant. When one does not, the only way to answer "all results for this student" is to ask every organisation and union what returns.
- Membership-driven fan-out. A resolver like
megVedLarested("me, at each institution") today bypasses@serviceand hand-writes the loop: for each tenant the logged-in user belongs to, open that tenant's connection, call the service, drop nulls, union. The service method is plain GraphQL-free Java; what does not fit codegen today is the per-tenant connection plumbing and the parallel orchestration.
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.
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.
R45 fails the build, naming the field. No guessing.
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?
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:
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.
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?
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?
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:
- Claims-extraction seam. How graphitron reads the tenant set out of the claims: a consumer-derived collection contextArgument (e.g.
Set<Long> tenantRoles, keeping graphitron claims-format-agnostic) or a claims-map plus configured extraction. The pre-derived contextArgument is the lighter seam and matches how the hand-written resolver already reads its roles. - Marker syntax. A directive (something like
@fanOut) or a list-typed contextArgument naming the tenant subset, reconciled with R45's inference posture (fan-out is asked for, never inferred). - Result semantics. Ordering across the union; pagination and
@asConnectionover a fanned-out field; per-tenant partial failure (drop nulls vs surface the error), composing with the typed-errors work. - Parallelism bounds. Fanning out to dozens of databases per field needs a concurrency cap and a timeout story, likely R429 config.
Where it stands (as of 2026-07-17)
- backlogR46 (this concept) is Backlog. Resolved so far: the fan-out domain (map keys ∩ role-bearing claims, with the two difference directions treated oppositely) and that mechanics ride R429. Open for the Spec pass: claims-extraction seam, marker syntax, result semantics, parallelism bounds.
- pendingDepends on R45 (operation-divined tenant routing, Spec): the
TenantBindingaxis this item adds its arm to, and the tenant-index routing that makes fan-out the fallback rather than the default. - landedDepends on R429 (connection lifecycle, in the changelog): acquisition, per-tenant transaction demarcation, and session state, the threading fan-out must not reimplement.