ID |
|
|---|---|
Status |
In Progress |
Bucket |
feature |
Theme |
runtime-connection |
Created |
2026-10-05 |
Updated |
2026-10-06 |
A root @service whose only tenant carrier is a scalar input member cannot route under database-per-tenant
Goal
Under database-per-tenant routing, where each tenant’s rows live in that tenant’s own database and graphitron picks the database from what the operation names, a root @service can route on a plain scalar that the schema author marks as the tenant with a new @tenant directive, on an argument or on a member of a hand-written input bean or record. Today such a service is refused with "nothing in its arguments names a tenant" unless its arguments reach a jOOQ record field bound to the tenant column or a decoded @nodeId, and the common "create X at institution Y" mutation has neither: no row or id exists yet, and the only thing naming the tenant is a scalar like eierOrganisasjonskode: String!. With the marker the service runs on that tenant’s connection, and the tenant is handed down to the tenant-scoped fields under its payload, exactly as an id-carrying service’s is today.
input OpprettStudentInput {
eierOrganisasjonskode: String! @tenant
fodselsnummer: String!
}
type Mutation {
opprettStudenter(input: [OpprettStudentInput!]!): OpprettStudenterPayload
@service(service: {className: "...StudentService", method: "opprettStudenter"})
}
The marker is the author’s statement about an opaque service, of the same kind as @globalData: graphitron cannot see that the service writes the value into the tenant column. What graphitron does check is everything around that statement. The marked value must have the tenant key’s Java type, every value in one call must name the same tenant (a batch naming two is refused before the service runs), and the routed tenant must be in the request’s permitted tenant set, so a client can never reach a tenant it is not entitled to by sending a different value.
The pair that pins it, over the TenantBindingClassificationTest fixture (film_id as tenant column, so the tenant key type is Integer):
input RateByFilmIdInput { filmId: Int! rating: Int }
type Mutation {
rateByFilmId(in: RateByFilmIdInput!): RateFilmsPayload
@service(service: {className: "...TenantServiceStub", method: "rateByFilmId"})
}
public static TenantFilmsPayload rateByFilmId(DSLContext dsl, RateByFilmIdBean in) { ... }
Before (and still, without the marker): Mutation.rateByFilmId is refused with UnroutedServiceCall, and RateFilmsPayload.films with "no ancestor established a tenant context". After, with filmId: Int! @tenant: Mutation.rateByFilmId is ArgumentBound with one slot named in.filmId, read as NestedInput("in", ["filmId"]) with the Raw projection, RateFilmsPayload.films is Inherited, and there are no rejections.
The motivation is the sis Graphitron 10 migration (<tenantColumn>INSTITUSJONSNR_EIER</tenantColumn>, validated against trunk at 17196b8eb). There the R978 refusal fires on 20 root @service fields. Nine were fixed on the sis side by typing a bean member as the node table’s jOOQ record with @nodeId. The remaining eleven are create-mutations (opprettFagpersonerGittFodselsnumre, opprettStudenter, registrerInnreisendeUtvekslinger, overfoerTilHovedbok and others) whose only tenant carrier is the scalar above. Every workaround available today distorts the API or the Java: restructuring the input breaks the wire format of stable fields, a dummy jOOQ-record parameter changes eleven signatures, and @globalData would write tenant data to the default database. Graphitron 9 routed these by argument name.
User documentation (first-client check)
The service paragraph of docs/manual/how-to/tenant-scoping.adoc ยง 2 that begins "A service’s own SQL is opaque to graphitron" becomes:
A service’s own SQL is opaque to graphitron, so a root service that is handed a
DSLContextor$sessionmust name its tenant in its arguments, or the build refuses it rather than run it on the default source. Take the node table’s jOOQ record with@nodeId(typeName:)instead of the encoded id as aString, and the call routes on the id. When no row exists yet and the tenant arrives as a plain value, as in a mutation that creates something at an institution, mark the argument or input field holding it with@tenant:input CreateStudentInput { institution: String! @tenant name: String! }The call then routes on that value, which must have your tenant key’s Java type. Like any routed value it is checked against the request’s tenant set, and every value in one call, across a batch and beside any tenant-bearing ids, must name the same tenant. The marker is your statement that the service uses the value as its tenant, which graphitron cannot see. A service that works only on global tables, or only reads the mounted session handle, says so with
@globalData, and runs on the default source. The build rejects@globalDatawherever it can see tenant data: a tenant-scoped@tablereturn, an argument that names a tenant (an id carrying it, or a@tenantvalue), or an argument typed by a tenant-scoped table’s record. The reverse holds too: a service that takes a tenant’s record runs on that tenant’s database, so it should not take one if it only works on global tables.
A new reference page, docs/manual/reference/directives/tenant.adoc, listed in docs/manual/reference/directives/index.adoc beside @globalData:
@tenantmarks the argument or input field of a root@servicefield that carries the tenant, in a build with database-per-tenant routing (<tenantColumn>). The service runs on that tenant’s connection, and the tenant-scoped fields under its result inherit it.directive @tenant on ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITIONPut it on a scalar argument of the service field, or on a scalar field of an input object the service receives as a Java bean or record, at any depth and inside lists. The value is read as the client sent it, so its GraphQL type must arrive as your tenant key’s Java type:
StringorIDfor aStringkey,Intfor anIntegerkey, a custom scalar whose Java type is the key type. Graphitron does not convert or normalise the value; one the request’s tenant set does not contain fails the field with a client error.The build rejects the marker where it decides nothing: in a build without
<tenantColumn>; anywhere the field it reaches is not a root@service(a query field binds the tenant through@field(name:)on the tenant column, and a child service inherits its parent’s tenant); on a field bound into a jOOQ record (bind it to the tenant column with@field(name:)instead); beside@nodeId(the id already carries its tenant); on a field that is not a scalar; on a service whose result is a global@tabletype, which runs on the default source; and on a value whose type is not the tenant key’s.
Implementation
The slot machinery downstream of a minted slot needs nothing new: TenantDslEmitter already renders TopLevelArg and NestedInput reads with the Raw projection, the generated divinedTenant fold already flattens list levels and refuses disagreement, and TenantConnections.entryFor already checks the routed tenant against the request tenant set (R975). The work is the directive, getting the marked leaf into the fold as a model fact, the build-time ladder, and freeing BoundSlot from the table column a scalar does not have.
-
The directive.
graphitron-model/.../schema/directives.graphqlsdeclaresdirective @tenant on ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITIONwith a description condensed from the reference page above;BuildContextgainsDIR_TENANTbesideDIR_GLOBAL_DATA. No store relation: a directive with no arguments gets nographitron_decode relation (see the DDL comment near the directive relations ingraphitron-model.sql), and its applications are already in thegraphql_ast_*capture. -
The leaf’s definition coordinate on
ValueShape.Scalar.ValueShape.Scalar(TypeName javaType, ArgPath sdlPath, CallSiteExtraction leafTransform)gains aString definition: the schema coordinate of the SDL slot the leaf reads, spelled throughSchemaCoordinateSyntax(RateByFilmIdInput.filmIdfor an input field,Mutation.rateById(filmId:)for a top-level argument).ArgPathis a use-site path, not a key to the definition, which is why the coordinate is stamped where the definition is in hand rather than recovered from the path. For a bean or record member that isInputBeanResolver, which holds theGraphQLInputObjectFieldwhen it buildsCallSiteExtraction.FieldBinding;FieldBindingcarries the coordinate andServiceMethodCallWalker.fieldBindingShapecopies it onto theScalar. For a top-level argument, and for an argument bound through anargMappingpath into a nested input field (theNestedInputFieldunwrap inServiceMethodCallWalker.deriveValueShape), the walker derives it from the service field’s coordinate and the binding’s path. EveryScalarconstruction site carries it, including the defensiveDirectfallbacks. The coordinate is an identity fact, not a tenancy flag, so the fold joins it against the marker’s applications and owns the whole marker ladder in one place. -
Minting, in
TenantBindingIndex.Fold. The fold computes once, from the SDL, the set of@tenantapplication coordinates.collectFromValueShape’s `Scalararm passesscalar.definition()tocollectFromServiceLeaf, whoseDirectcase, when the definition is marked, mintscollector.add(slotNameOf(path), new SlotAccess.Resolved(readOf(path), SlotProjection.Raw.INSTANCE)), provided the type rung below passes, and records the(use coordinate, definition)pair as consumed.ListOfalready recurses, so a list of beans and a[String!] @tenantlist both read through the existing list-mappingtenantSlotwalk. A marked definition reached through any other leaf (NodeIdDecodeRecord,NodeIdDecodeKeys,EnumValueOf) mints only what that leaf mints today and leaves the pair unconsumed for the sweep.collectFromServiceCall’s global-reach gate stays first, so a service returning a global `@tabletype mints nothing from a marked value either. Because a marked slot is an ordinary slot, the@globalDataladder’s "the arguments name a tenant (in.filmId)" rung and theSlotCollectoragreement fold apply to it with no change. -
The type rung. The marked slot’s wire Java type comes from
ScalarTypeResolver.coercionOutputTypeover the definition’s GraphQL scalar (looked up in the SDL by the coordinate, element type for a list), the one forward mappingWireCoercionResolveralready uses, not fromScalar.javaType(the bean member’s declared type, whichWireCoercionResolver.checkScalarlets through unjudged for an unknown custom scalar). It must equal the boxedTenantScopes.Configured.tenantType(). Equality is required rather than convertibility because the generatedagreeOnTenantcompares candidates withequalsbeforedivinedTenantcoerces anything, and a decoded@nodeIdsibling’s tenant-slot helper returns the typed key: anID-typed marked value"1"beside a decoded1would be refused as a disagreement at request time. A mismatch, or a scalar whose output type is unknown, is a decline (collector.decline(...)) naming the scalar, its Java type and the key type, so a connection-binding service gets oneUnroutedServiceCallcarrying that text and nothing routes on a value the fold might misjudge. Graphitron normalises nothing: a value with leading zeros that the request tenant set holds without them is refused at request time, which fails closed. -
The marker sweep. Beside
forEachMarkerApplication, which walks field definitions and so cannot see argument or input-field applications, a second sweep judges every use site of every application. For each output field coordinate it walks the field’s argument input trees (cycle-safe; input types can recurse) and collects the(use coordinate, definition)pairs reaching a@tenantapplication; every pair the fold did not consume gets aRejection.directiveConflict(List.of("tenant"), reason)at the definition coordinate, naming the use coordinate. Per use site rather than per definition, because one input type is consumed by many fields: a definition consumed at one root service and silently ignored at a child service or a query input would leave the second site running on a tenant the marked value never had to agree with. The reasons, first match wins: no<tenantColumn>configured (this arm alone runs inrejectMarkersWithoutTenancy’s single-tenant path); the use site is not a root `@service(query and@mutationfields bind the tenant through@field(name:)on the tenant column; a child service inherits its parent’s tenant); the definition is a non-scalar input field; the definition carries@nodeId; the definition is bound into a jOOQ record parameter (name@field(name: "<tenantColumn>")); the service’s reach holds a global table; and the type rung declined. Where the fold also refuses the field, as it does for a connection-binding service whose only carrier is a mistyped marker, the author sees both messages, the sweep’s at the definition and the fold’s at the field; that is accepted rather than threading a suppression between the two. -
Drop the table column from
BoundSlot. A scalar slot has no table, so it has noColumnRef.TenantBinding.BoundSlot(String slotName, ColumnRef column, SlotRead read, SlotProjection projection)losescolumn. Its one main-code reader isRoutineWriteCommands.acquisitionOf, which passes the primary’s column asTenantAcquisition.ArgumentBound.keyColumnsoTenantAcquisitionFragments.divinedKeycan type the_divinedTenantlocal. That is a second route to a run-wide fact already held once, onTenantScopes.Configured.tenantType()and enforced by the catalog-wideTenantColumnTypeDisagreementrejection, andTenantDslEmitteralready reads it there.ArgumentBoundloseskeyColumn;TenantRouting.RoutedgainsTypeName tenantKeyType, filled inRoutineWriteCommandsfromGraphitronSchema.requestTenantKeyType();divinedKeyreads it. The javadoc onTenantAcquisition.ArgumentBoundthat justifieskeyColumnand the one ondivinedKey("the bound column’s own Java type") are rewritten to nameTenantRouting.Routed.tenantKeyType.SlotCollector.addand everycollector.addcall site drop the column argument. A synthesised representative column was the alternative and is worse: it names a table the slot has nothing to do with. -
Refusal text. The fixes clause of
Rejection.AuthorError.UnroutedServiceCall.message()gains the new carrier: "… or bind a jOOQ record field to '<col>', or mark the scalar argument or input field that holds the tenant with @tenant."TenantBindingIndex’s `@globalDataevidence rung text ("Take the node table’s jOOQ record with @nodeId(typeName:), or bind a jOOQ record field to …") gets the same clause. -
Docs. The manual paragraph and the reference page above; the
@globalDatadescription indirectives.graphqlsandglobalData.adocneed no edit, since "fields whose arguments name a tenant" already covers a marked value. -
Execution fixture.
graphitron-sakila-example/src/main/resources/graphql/multitenant.graphqlsgainsRateByFilmIdInput { filmId: Int! @tenant rating: Int }and arateFilmsByFilmId(in: [RateByFilmIdInput!]!)root@servicefield onFilmRatingService(ingraphitron-sakila-service), taking aDSLContextand a hand-written bean, returning the existing payload shape withranOnread fromcurrent_database(). A second field adds anID! @nodeId(typeName: "Film")member beside the marked one for the agreement case.
Tests
Classification tier, in TenantBindingClassificationTest beside the R976 service cases, over SERVICE_TYPES with new TenantServiceStub methods and a RateByFilmIdBean (plus a Java record twin):
-
The pair:
anUnmarkedScalarMemberHandedAConnectionRejectsAtTheRoot(the before half,UnroutedServiceCallnaming@tenantin its fixes) andaTenantMarkedScalarMemberDivinesRawAndItsChildrenInherit(the after half exactly as the Goal states it). -
A marked top-level argument,
rateById(filmId: Int! @tenant): slotfilmId,TopLevelArg,Raw. -
A marked Java record member, and a member of a nested grouping input: slot path includes every segment.
-
A list of beans,
in: [RateByFilmIdInput!]!:NestedInput("in", ["filmId"]), one slot. -
A marked scalar beside a
@nodeIdmember: two slots,in.filmId(Raw) thenin.film(DecodedKeySlot), in declaration order. -
@globalDataover a marked argument: the "the arguments name a tenant (in.filmId), which contradicts @globalData" rung. -
The type rung:
filmId: String! @tenantandfilmId: ID! @tenantagainst theIntegerkey each yield oneUnroutedServiceCallcarrying the decline text, plus the sweep’s rejection at the definition. -
The sweep, one case per reason: a single-tenant build; the marker on a query
@tablefield’s argument; on a child service’s argument; on an input-object-typed field; beside@nodeId; on a jOOQ record member; on a service returning a global@tabletype. Each asserts thedirectiveConflictat the definition coordinate naming the use site. -
One input type reached by a root service and by a query field: the root service is
ArgumentBound, and the query use site is rejected, which is the per-use-site reason for the sweep.
Pipeline tier: the tests already pinning TenantAcquisition.ArgumentBound and the routine-write divinedKey declaration keep passing with the key type read from TenantRouting.Routed; assertions on BoundSlot.column().sqlName() are deleted with the component, since every minted column matched the configured name by construction.
Execution tier, in TenantDivinedRoutingExecutionTest over the fixture above:
-
service_routesOnATenantMarkedScalar_andHandsItDownToTheReturnedRows:filmId: 1runs ontenant_1, its films and their inventories resolve, andtenant_2is never opened. -
service_batchOfMarkedScalarsNamingTwoTenants_isRefusedBeforeTheServiceRuns: zero acquisitions, "Tenant bindings disagree". -
service_markedScalarDisagreeingWithASiblingNodeId_isRefused:filmId: 1beside the encoded id of film 2, refused with zero acquisitions; the agreeing pair routes. -
service_markedScalarOutsideTheRequestTenantSet_isRefused: a request permitted only tenant 2 sendingfilmId: 1fails with the client error, no connection opened.
Completeness rests on the classification pair, the four execution cases, and the multitenant example building with the new fixture.
Retired vocabulary
BoundSlot.column, TenantAcquisition.ArgumentBound.keyColumn, and the javadoc phrase "the bound column’s own Java type". The retirement sweep greps for keyColumn within command/, plan/ and render/, and for .column() on a BoundSlot across the tenant tests.
Other solutions we’ve considered
-
Reuse
@field(name: "<tenantColumn>")on the bean member. On a bean or record member@field(name:)names the Java member it binds (InputBeanResolver.bindingKey), and on a top-level@serviceargument it is ignored; giving it a second meaning that depends on the value naming the configured column would make one directive mean two things by coincidence of spelling. Where it does name a column, on a jOOQ record member, it already routes, which is why the marker is rejected there. -
Route by argument name, as Graphitron 9 did. A naming convention is invisible in the schema and fires on any field that happens to share the column’s name; the marker is a statement the author makes and the build can check.
-
A boolean
tenantCarrieronValueShape.Scalar, or a wrappingCallSiteExtractionarm. The boolean spreads the marker’s rejection ladder acrossInputBeanResolver,ServiceCatalogand the fold, and loses the coordinate the per-use-site sweep keys on. A wrapping extraction arm fuses the tenant role onto the extraction-strategy axis, so the permit set becomes their product. The definition coordinate is the plain fact both need. -
Convert the marked value to the key type (
StringtoInteger, normalising leading zeros).divinedTenantalready parses aStringfor a numeric key, but the agreement fold compares first, so a converting marker would need the fold reworked to coerce before comparing, which changes every existing slot’s runtime path. Exact type equality covers the reported case (aStringkey withString!values) and the sakila fixture (IntagainstInteger); conversion can be its own item if a schema needs it.