← Roadmap

Concept explainer · R458 · theme: interface-union

Per-participant join paths for polymorphic child fields

Why @referenceFor exists, and the intuition behind it, so the R458 plan reads faster afterwards.

This page builds intuition; the plan is the spec (exact call sites, slice boundaries, rejection wording). Read this first, then the plan.
The one-sentence version When a GraphQL field returns an interface or union whose members live in different database tables, graphitron must know how to join from the parent to each member's table; @referenceFor lets an author state that join per member for the cases graphitron cannot guess on its own.

The problem, concretely

An Address has an occupant, and an occupant is either a Customer or a member of Staff:

type Address {
    occupant: AddressOccupant   # union of Customer | Staff
}

Customer and Staff are backed by different tables (customer, staff). To resolve occupant for one address, graphitron emits SQL that correlates the address row with the right customer or staff row. That correlation is a join path, and it is different for each member.

What works today: auto-discovery

graphitron looks for exactly one foreign key from each member's table back to the parent's table and uses it.

One obvious FK per member? You write nothing. It just works.

What breaks: everything else

Zero, or more than one, candidate FK, and auto-discovery cannot choose. The old code picked an arbitrary FK and emitted silently wrong rows.

That is the worst failure a generator can have: nothing tells you it happened.

Four shapes defeat auto-discovery:

Quiz Sakila's payment table has a single FK customer_id to customer. A field returns a union where Customer is one member, correlated to the parent by that one FK. Does auto-discovery handle it?

Auto-discovery stays the default. @referenceFor is only for the members it cannot resolve on its own; a single clean FK needs no annotation at all. The whole design is override-only.

Why this is two roadmap items, not one

R452: the defensive half (shipped)

Makes graphitron reject all four rich shapes at build time instead of guessing. Closes the silent-wrong-data hole. After R452 you get a clear error, but you also cannot express these shapes yet.

R458: the constructive half (this page)

Gives you the words to say what you mean, so the shapes R452 rejects become shapes you can build. R452's rejection messages literally point forward at @referenceFor.

Splitting it this way keeps trunk safe throughout: the hole is closed first, the capability arrives second. See R452 in the changelog (its plan file is gone; Done items live in the changelog).

The idea: say the path per member

@referenceFor is applied once per member. Each application states one fact at its natural grain: for this member, here is the path from the parent.

type Address {
    occupant: AddressOccupant
        @referenceFor(type: "Customer", path: [{key: "customer_address_id_fkey"}])
        @referenceFor(type: "Staff",    path: [{condition: {className: "...", methodName: "..."}}])
}

The path uses the same element grammar as @reference ({table:}, {key:}, {condition:}), so paths are nothing new. What is new is the per-member binding, and that you only annotate the members auto-discovery cannot handle.

Why a brand-new directive, and not just @reference?

This is the part worth slowing down on. A field carries a single @reference path, and for a polymorphic child that one path is applied against every member's table in turn, so it can be terminally correct for at most one member. One field, one path, many members: the arithmetic does not work.

There is a subtler reason too, and it is a genuine trap. The two directives share the path grammar but mean opposite things when repeated:

@reference repeated → concatenates

Applications join end to end into one running chain, in authored order. Think "walk A, then B, then C." (That is R435's rule.)

@referenceFor repeated → independent

Applications are keyed by type: and stand alone. Each path: is the complete path for one member. No chaining axis; repeating the same type: is a build error.

Quiz A field carries @referenceFor(type:"Customer", path:[A]) and @referenceFor(type:"Customer", path:[B]). What happens?

Concatenation is @reference's rule, not @referenceFor's. Because @referenceFor applications are independent and keyed by member, two entries for the same member are contradictory, so they are rejected rather than silently merged or overwritten. This opposite-repetition-semantics-on-a-shared-grammar hazard is exactly why the capability got its own directive instead of an argument on @reference.
Alternatives the design rejected, and the principle behind it

Two other shapes were considered and dropped: reviving the old @multitableReference(routes: [...]) wrapper, and adding a for: argument to @reference. Both smeared two independent facts (which member, which path) into one construct, or widened @reference's contract at call sites where the extra argument is meaningless. The through-line is a graphitron principle: a directive should carry exactly what the author needs to say, at the grain they need to say it. The correlation is a fact of the (field, member) pair, so @referenceFor binds at that grain.

How to picture what gets generated

You do not need emitter internals to hold the shape in your head. Every member's correlation resolves to one of two kinds, decided once when the schema is classified:

KeyTupleWhere: match, don't join

The parent already knows its own key values; the member row is found by matching columns against those values. No new table is joined.

FK disambiguation and self-FK both land here.

JoinedCorrelation: actually join

One or more hops, each either an FK bridge or a condition predicate.

Multi-hop and condition correlation land here.

That two-way split is a sealed type with two arms. Sealed variants are how graphitron models "one of a fixed set of shapes, handled exhaustively"; see Dispatch axes for why the generator leans on this instead of flags and null checks.

Quiz You disambiguate a member that has two FKs to the parent by naming one with @referenceFor(type:"Film", path:[{key:"..."}]). Which arm does it lower to?

Disambiguation only chooses among FKs auto-discovery already saw; the correlation is still parent-key-values against member columns, so it stays in the no-join arm. A join hop appears only when you reach through an intermediate table (multi-hop) or correlate by a predicate (condition).

Why unfinished shapes fail loudly on purpose

R458 ships in slices, and here is the tidy bit: the directive grammar accepts all four shapes from day one, but the emitters learn them one slice at a time. In between, any shape whose emitter has not shipped is rejected at build time with a message naming the slice it is waiting on, never silently mis-emitted.

This is graphitron's typed-rejection discipline: a shape the generator cannot yet handle correctly is a first-class rejection with an explanatory message, not a best-effort guess. Same instinct as R452 closing the hole before R458 fills it, applied slice by slice so trunk stays green against any schema.

Quiz During slice 1 (only the no-join cases shipped), you write a @referenceFor with a {condition: ...} path. What happens at build time?

The grammar accepts the condition path, but slice 1's classifier physically cannot build the joined-correlation arm, so it rejects with a deferred message pointing at the pending slice. Loud and specific beats silent-and-wrong every time; that is the whole typed-rejection stance.

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