ID

R333

Status

Ready

Bucket

structural

Priority

3

Theme

classification-model

Created

2026-06-18

Updated

2026-08-11

The Graphitron data model

In one paragraph

Every GraphQL field has a coordinate, its (parentType, fieldName), and Graphitron’s job is to turn each coordinate into the Java that resolves it. Today it does so by mapping each coordinate to one large "leaf" object that welds together everything the field needs at once, and those leaves multiply into a cross-product (Split x Lookup x Composite x …​) that this spec exists to dissolve. The dissolution has two halves.

  • Front half, the facts. Instead of one fat leaf per field, store a handful of small, independent facts about the coordinate, each found by its own walk: where the source object arrives from (source), what the field returns (target), which operations it triggers (operation: select, join, paginate, condition, …​), whether its value crosses a table (reference), and how to read the value back out (the read-side facts). Adding a capability adds a fact, not a new leaf type.

  • Back half, the method graph. Instead of one tangled method per field, emit a graph of small Java methods that call each other by name, where each method sits at its own natural granularity (one per field, one per type, one per query launcher, …​). The points where one method calls another are seams. The graph must be closed: every name a method calls is a method we also emit.

The two halves meet at the operation relation: a coordinate’s facts decide which seams its query composes. A worked example is film.language: one coordinate, whose facts are a source (a Film row arrives), a target (it returns one Language), and a reference (the value lives on another table, reached by a foreign key); together those trigger a join and a select. No leaf type encodes "single table field reached by a reference"; the facts do, and they add rather than multiply.

This item is the model: the facts, the method graph, and the integrity check that ties them together. Re-platforming the generator’s emit onto the model was executed by the R549 (facts-and-commands) programme (Done 2026-08-01; see roadmap/changelog.md), which consumed this model slice by slice after R314 (Done) shipped its reentry beachhead; the front half (the operation relation materialized as walked facts, dissolving the classifier’s operation-encoding leaves) was delivered by the R563 (operation-relation) programme (Done), as the OperationMember per-coordinate member multiset. The model now also has a second, executable home: the fact schema DDL in the graphitron-model module (R595) is this document’s normalised data model reified as a SQL schema, and the classification-stage derivations over it are R589’s subject. The stabilized why is migrating out section by section to docs/architecture/explanation/fact-model.adoc (a migrated section keeps its heading with a pointer body); suggested reading order is that page and this orientation, then The model (the ER diagram and the fact catalog), then the detail: the front half (The normalized schema: the coordinate and its facts) and the back half (Operations are realized by seams). The lettered Discovery threads (A-K) at the end record how the model was derived by walking the current emitters; they are the evidence, referenced throughout, not prerequisites to read first.

The model

The entity is the schema coordinate, the GraphQL spec’s SchemaCoordinate stored decomposed into its grammar columns (the key system is The natural keys below; Foo.bar is the output-field case). The model is that coordinate together with a small set of facts, each an independent functional dependency found by its own walk. The leaf zoo, the per-field "graphitron field", and the two library types (DataFetcher, QueryPart) are denormalized views over these facts; a capability is added by adding a fact, not a new leaf type. The whole model at a glance:

erDiagram
    COORDINATE ||--|| SOURCE : "arrives via"
    COORDINATE ||--|| TARGET : "returns"
    COORDINATE ||--o{ OPERATION : "triggers (a set)"
    COORDINATE ||--o| REFERENCE : "crosses a table"
    COORDINATE ||--o| RESOLVED_TABLE : "resolves against"
    COORDINATE ||--|| ACCESSOR : "value read by"
    COORDINATE ||--o| DISCRIMINATION : "recovers concrete type"
    COORDINATE }o--|| SOURCE_OBJECT : "cast target (type-level)"
    COORDINATE ||--o{ CAPABILITY_TAG : "@capability (reserved)"
    COORDINATE ||--o| SOURCE_LOCATION : "locate (joined at request time, not stored)"
    COORDINATE ||--o{ AUTHORED_CLAIM : "classification claimed by directive"
    COORDINATE ||--o{ INFERRED_CLAIM : "classification claimed by structure"

    REFERENCE ||--|| REFERENCED_TABLE : "destination"
    REFERENCE ||--|{ JOIN_STEP : "linearized join graph"
    JOIN_STEP ||--|| TABLE_EXPR : "target node"

    OPERATION ||--o| ERROR_GUARD : "if it can throw"

    ACCESSOR ||--o| NODE : "@nodeId codec"
    ACCESSOR ||--o| ENUM : "enum coercion"

    CAPABILITY_TAG }o--|| SLUG : "names (slug namespace)"

    COORDINATE {
        enum kind "one of five SchemaCoordinate variants (sealed)"
        key coordinate PK "per-variant Name columns, all non-null; see The natural keys"
        string canonical "derived render of the key: Foo / Foo.bar / Foo.bar(baz:) / @foo(bar:)"
    }
    SOURCE {
        enum kind "Root | OnlyChild | Child (sealed)"
        shape shape "carried by OnlyChild / Child only: Table | Record (Root carries none)"
    }
    TARGET {
        enum wrapper "Single | List"
        enum shape "Column | Table | Record | Field"
    }
    OPERATION {
        enum kind "select join paginate condition orderBy serviceCall DML (sealed; payload varies by kind)"
        anchor address "which query unit it lands in"
    }
    REFERENCE {
        path path "authored @reference or inferred FK (coalesced view)"
    }
    RESOLVED_TABLE {
        table table "derived view: referencedTable then source then target"
    }
    JOIN_STEP {
        int stepIndex
        on on "sealed On: ColumnPairs | Predicate | Lateral (on Hop; LiftedHop carries lifted slots; shipped by R438/R435)"
    }
    TABLE_EXPR {
        enum arm "Catalog | RoutineCall (sealed; RoutineCall carries a callable + arg bindings)"
    }
    SOURCE_OBJECT {
        class castTarget "never a table"
        bool tableBound
    }
    ACCESSOR {
        locator locator "sealed read mechanism: typed jOOQ field | by-name field | record/getter | passthrough | localContext"
    }
    NODE {
        string type
        table table
    }
    ENUM {
        string name
        type backing "Java enum | String | numeric (derived)"
    }
    DISCRIMINATION {
        enum domain "row | exception (sealed; signal family depends on domain)"
        enum signal "row {RecordClass | DiscriminatorColumn} or exception {ExceptionClass | SqlState | VendorCode | Validation}"
    }
    ERROR_GUARD {
        enum channel "Outcome | PayloadClass | LocalContext"
        ref handlerSet "interned partition"
    }
    CAPABILITY_TAG {
        slug slug "@capability / @exemplifies edge; reserved, not yet shipped"
    }
    SLUG {
        string name PK "capability catalog: third referenced namespace"
        string definition
    }
    SOURCE_LOCATION {
        string uri "value of locate; partial (absent for built-ins)"
        int line
        int column
    }
    AUTHORED_CLAIM {
        key classifier PK "with the coordinate; one view arm per claiming graphitron_ relation"
        payload slots "decoded slot facts; provenance is the application and its location"
    }
    INFERRED_CLAIM {
        key classifier PK "with the coordinate; one derivation view per structural classifier"
        payload witnesses "the join witnesses that proved the claim"
    }

The diagram is itself a denormalized view, the same move as the leaf zoo is a denormalized view below. A discriminator marked (sealed) is a tagged union, not one relation with nullable-by-arm columns: each variant carries only its own non-null columns (concrete-table inheritance, exactly like the coordinate key in The natural keys), and the per-variant columns are normalized in that fact’s deep-dive below. Flattening a union into one box with a kind / arm / domain enum is the diagram’s convenience, not the model’s shape; in the type system each is a sealed interface (Source, OperationMember, JoinStep, TargetShape, ErrorChannel, the discrimination signal). The unmarked entities are genuine single relations.

The catalog, each row a fact with its own deep-dive below:

Fact Cardinality Sourced from Role

source

1:1, total

parent + the edge into the field

where the source object arrives and how many; the parent shape

target

1:1, total

field.getType()

the field’s own output wrapper (Single / List) and shape

operation

0..N (a set)

@service / @condition / @orderBy / @mutation / @asConnection / a table-bound return

the QueryPart-emitting commands the query unit composes

reference

0..1

@reference, else inferred unique FK

the value lives off the parent’s table; lowers to a join

referencedTable

0..1 (iff reference)

@reference

the reference’s destination table, named in its own right

resolvedTable

0..1, derived

coalesce referencedTable ?? source.table ?? target.table

the catalog table the @field resolves against

tableExpr

per table node

@routine

materializes a table node (Catalog | RoutineCall)

joinPath

the `reference’s resolved form

@reference

the linearized join graph: a start node plus ordered `JoinStep`s

source object

type-level

the parent type’s record shape (@sourceRow for a DTO)

the cast-target record the read casts to; never a table

accessor

1:1 (read side)

@field / @externalField

the field-level locator that reads the value back out

node

accessor refinement, 0..1

@node / @nodeId

the nodeId codec and its key projections

enum

accessor refinement, 0..1

@field on ENUM_VALUE

the authored value set and a derived backing type

discrimination

0..1

@discriminate / @discriminator / @error

concrete-type recovery, over the row or exception signal domain

errorGuard

operation sub-fact

@error

on a throwing operation: a transport channel and an interned handler partition

capabilityTag

0..N (reserved)

@capability / @exemplifies

tags the coordinate with a stable slug from the capability catalog; knowledge-surface only, not yet shipped

sourceLocation

0..1, split by namespace

SDL positions stored as capture columns; Java/Javadoc positions joined against SourceWalker.Index at request time

the locate value; a fact about the coordinate, never itself a key (see Location)

authoredClaim

0..N, keyed (coordinate, classifier)

one view arm per claiming graphitron_ relation

the classification axis’s authored claims; the reduced view is the single-classification worldview planning reads (R589)

inferredClaim

0..N, keyed (coordinate, classifier)

one derivation view per structural classifier

the masked structural reading, payload the classifier’s join witnesses; resolution unions authored with inferred at uncovered coordinates (R589)

The model is closed. Every active directive’s effect has an owning fact in this catalog; the completeness audit, directive by directive, is Directive coverage near the end. The two halves of the lowering, these facts (front) and the method graph that consumes them (back), meet at the operation relation, detailed throughout the rest of this document.

The model has an executable home, and the division of labour is explicit. The fact schema DDL in the graphitron-model module (R595) is this normalised model reified as SQL: the shipped families (graphql_ total transcription, graphitron_ decoded directive semantics, sql_ catalog, jvm_ classpath, store_ bookkeeping, javac_ compile diagnostics per R603, with graph_name leading the SDL-family keys per R610) carry the base relations, and the derived stratum (the claim views, resolution, reachability, demand, the diagnostics relations) lands consumer by consumer per R589’s strangler frame. The stage vocabulary over that home is three verbs, each stage only ever adding rows: classification gathers* (capture loads the base relations and the claim views read them), validation derives (a violated constraint becomes a located violation fact; the claims stay), and *planning joins facts directly into command records, which are the parse targets. This document keeps the why not yet migrated: the stabilized why moves section by section into docs/architecture/explanation/fact-model.adoc (a migrated section below keeps its heading with a one-line pointer as its body). The what migrates into the DDL relation by relation, and where prose and DDL disagree the DDL wins; which relations exist at any given moment is the DDL header’s record, not this document’s, so a reader should not expect a relation per deep-dive yet.

The natural keys

The model’s spine is its natural keys, and the entity key is not ad-hoc: it is the GraphQL specification’s SchemaCoordinate, the same coordinate this document is named for. The spec already standardizes the grammar for addressing every element of a schema, so the model adopts it rather than inventing a key. (Later sections still sketch the coordinate as (parentType, fieldName) for readability; that is shorthand for the MemberCoordinate output-field case. The ER diagram in The model draws the full key.) The shipped half of this key system (Name-column natural keys per relation, the rendered coordinate, the definition-keyed / use-keyed split) migrated to docs/architecture/explanation/fact-model.adoc, section "The key discipline"; the five-kind sealed coordinate carrier and the mechanical never-a-stored-surrogate rule below are target-state and stay here until a carrier and a gate exist.

The coordinate is a SchemaCoordinate, stored decomposed into its grammar columns. The spec’s five productions are five coordinate kinds, each carrying exactly the Name positions of its production, and the coordinate kind is just which production matched:

Kind Columns Canonical string

TypeCoordinate

typeName

Foo

MemberCoordinate

typeName, memberName

Foo.bar

ArgumentCoordinate

typeName, memberName, argumentName

Foo.bar(baz:)

DirectiveCoordinate

directiveName

@foo

DirectiveArgumentCoordinate

directiveName, argumentName

@foo(bar:)

The columns are what the model keys, joins, and indexes on: every member of a type is a prefix scan on typeName, every argument of a field a prefix scan on (typeName, memberName). The canonical string is a derived render over the columns, never the stored key. Spec fidelity earns three things from one render function: the string is the spec’s own serialization, it is the stable id the model-context surface hands an agent to traverse between answers, and it is the per-coordinate key the language server files its classification under. One coordinate system, one render, three readers, no second id grammar to maintain.

This is a sealed union, not one table with nullable columns. Reading the five kinds as columns of a single coordinate relation would make typeName / memberName / argumentName / directiveName nullable and push "which columns are populated" onto an unenforced convention keyed off kind: single-table inheritance, the denormalized shape this whole document argues against. The model is instead a sealed SchemaCoordinate with five variants, each a relation carrying exactly its own Name positions, all non-null. That is concrete-table inheritance, the relational name for the sealed hierarchies the project already builds in the type system, so the key obeys the same compile-time discipline as the facts that hang off it. The shared leading columns are real, typeName heads Type / Member / Argument and directiveName heads Directive / DirectiveArgument, and that shared prefix is exactly what the prefix scans key on; it is shared structure across variants, not one flat row with holes. The ER diagram in The model draws the coordinate as a single keyed box because its subject is the facts that hang off the coordinate, not the key taxonomy; the decomposition lives here. One reconciliation with the DDL: the capture schema stores output fields and input-object fields in a single graphql_field relation keyed (type_name, field_name), with the owning type’s kind distinguishing them by join. That is not the single-table inheritance this section argues against, because every key column is non-null and no column is populated-by-kind; the five-variant discipline governs the Java-side coordinate, while the capture relations key on the spec grammar directly and let the kind fall out of the type row.

MemberCoordinate is a single key covering what earlier drafts split into three: an output field (Film.language), an input-object field (FilmWhereInput.title), and an enum value (Color.RED, the EnumValue key the enum facts carry). All three are Name.Name; they were never distinct key spaces. The directive productions make directives first-class addressable elements, not merely things that annotate other coordinates: a directive definition is @foo, its argument @foo(bar:).

Input fields are member coordinates of their own input type, not a path under the consuming field. The spec is definition-keyed and has no dotted-path coordinate, so a nested input field is addressed as a MemberCoordinate of its input type (FilmWhereInput.actor, then ActorWhereInput.lastName), and the use-site "path" earlier drafts wrote as (typeName, fieldName, inputPath) is not a coordinate at all: it is a derived traversal through the coordinate graph, an ArgumentCoordinate followed by a chain of input-type member coordinates. The definition-keyed / use-keyed discipline this paragraph carried (authored facts at the definition member coordinate, use-site resolution a derived join over definition and consumer) migrated: see "The key discipline" in docs/architecture/explanation/fact-model.adoc.

Everything beyond the spec’s coordinate set is a relation over coordinates, never a new coordinate kind or an extra column. A directive application (a @reference written at Film.language) is an edge joining the host coordinate to the directive coordinate and carrying the applied argument values; the Directive coverage audit is exactly that edge set, and "this @reference lowers to a join fact" is one edge resolving to facts. The use-site binding above is a derived relation. Neither becomes a sixth coordinate kind. The coordinate stays a faithful, column-decomposed SchemaCoordinate; the model’s extensions sit outside the key.

The two referenced namespaces

A coordinate’s facts point into two external namespaces, and they are one shape seen from two angles: the jOOQ catalog (tables, columns, foreign keys, primary and unique keys, indexes) keyed by jOOQ identity, and the Java surface (classes, methods, parameters, record components) keyed by Java identity. Both are generated from source we write, the catalog by jOOQ codegen from the DDL, the Java surface by javac from the consumer’s classes, so both are complete and authoritative within a snapshot and both lag their source identically: an un-regenerated migration leaves the catalog stale exactly as an un-recompiled class leaves the Java surface stale. There is no completeness or authority difference between them; that lifecycle is common, the snapshot / freshness property applying uniformly to both.

Both back the same family of reads, distinguished only by which consumer reads them:

Read Access pattern Reader

resolve

unique-key lookup

code generation

validate

membership

diagnostics

complete

prefix-key scan

completion

describe

the description attribute

hover, the knowledge surface

locate

join to the position relation

go-to-definition

invert

reverse index

impact analysis, find-references

Code generation does only resolve; the model described the catalog as if that were the only read because code generation was the only consumer in view. The other five are what absorbing the language server and the knowledge surface adds, and they are one shape over both namespaces.

The two differ in exactly one way, and it is structural, not about authority. The catalog is a relation graph the model traverses: tables are linked by foreign keys, the reference fact’s joinPath walks them, node-key projection follows the foreign-key column pairings hop by hop. The Java surface is a flat namespace the model binds into: a class contains methods and a method contains parameters, but there are no edges between methods for a fact to traverse; a fact lands on a node and stops. The catalog’s heaviest machinery has no Java analog because the Java surface has nothing to traverse.

A Java method’s natural key is its signature, the name plus the ordered parameter types, because a method name alone is not unique in a class (overloads) the way a column name is unique on its table. Arity (a count of parameters) is a lossy projection of that signature and is not the key; a position join that comes back "ambiguous" is an artifact of keying on the projection rather than the signature, and it disappears once the signature is the key. Parameter names are not part of the identity (they cannot disambiguate overloads); they ride along as payload the model already carries for argument mapping and hover. The DDL implements this as jvm_method’s descriptor key (the JVM descriptor is the signature rendered canonically); the shipped `CompletionData.Method still carries no descriptor, which is why the capture agreement test compares that projection descriptor-erased.

What the model enables: three consumers

Migrated: the one-base-many-views layering, code generation as the narrowest view, and the re-sourcing invariant live in docs/architecture/explanation/fact-model.adoc, section "One base, many views". What stays here is the test-side obligation not yet discharged: the @classified corpus (ClassifiedHarness, CorpusDocuments.coveredLeaves(), VariantCoverageTest) still builds its verdict from the leaf zoo, so the harness is in scope for the re-sourcing and its coverage net needs restating once leaves are not the unit; widening the corpus directive to the fact set is filed separately.

Provenance, description, and capability

Three facts test the relational instinct that a fact’s origin is not automatically a column on the fact. The instinct and its shipped exemplars migrated (the Provenance and Description stubs below); Capability stays, because its directives are reserved, not shipped.

Provenance

Migrated: the provenance discipline (separate relations coalesced by views when the origins are independent walks, a column when one value fills one slot, the resolved value always a view, and the consumer split over the populations) lives in docs/architecture/explanation/fact-model.adoc, section "Provenance: pick the shape per fact".

Description

Migrated: the co-sourcing argument (description is a column on the entity’s own relation, not a separate relation) lives in docs/architecture/explanation/fact-model.adoc, section "Provenance: pick the shape per fact".

Capability

@capability (on a coordinate) and @exemplifies (on an operation) tag schema elements with a named, stable capability slug. This is authored data from the directive walk, it is multi-valued (a coordinate can carry several), and the slug it names lives in its own catalog (slug -> definition). So it is a separate relation, capability_tag(coordinate, slug), plus a referenced slug namespace, which is exactly the directive-application edge of The natural keys: (coordinate, @capability) -> slug. Code generation ignores it; the knowledge surface projects it ("which fields exemplify pagination", "what does this type deliver"). The slug catalog is a small third referenced namespace alongside the jOOQ catalog and the Java surface, generated from source we write (the capability catalog files) like the other two. This is also a gap in the audit: Directive coverage lists no @capability / @exemplifies. The directives are not yet shipped, so this reserves the slot rather than describing a built fact.

Derived reads, freshness, and location

The three recurring derivations this section named migrated; the stubs below point at their new home.

Derived data is a catalog of views

Migrated: the candidate space as an unconstrained relation read, diagnostics as located violation rows with many projections, stable ids as rendered keys, and reverse indexes as inverted functional dependencies live in docs/architecture/explanation/fact-model.adoc, section "Derived reads are views, not stored facts".

Freshness: the snapshot lifecycle

Migrated: freshness as a property of the snapshot, its two axes, and the per-consumer preconditions live in docs/architecture/explanation/fact-model.adoc, section "Derived reads are views, not stored facts".

Location: a fact about an entity, joined not stored

Migrated: the cadence rule (joined, not stored), the deliberately partial locator relation, the reverse-queryability-is-not-keyhood point, and the SDL-cadence exception live in docs/architecture/explanation/fact-model.adoc, section "Derived reads are views, not stored facts".

The unit is the emitted method

Migrated: the model statement (a schema coordinate is the input key to a lowering whose output is a referentially-closed graph of emitted Java methods; the leaf zoo, the per-field "graphitron field", and the two library types are denormalized or partial views of that one graph) lives in docs/architecture/explanation/fact-model.adoc, section "The back half: complete commands, a closed graph". The derivation chain, and the narrower superseded headline this section replaced, are recorded in Discovery below.

The method call graph is the granularity

The reason the field is the wrong unit is granularity: the methods we emit do not all sit at field granularity, so no single per-field model can source them. The method graph is the model precisely because it lets each node sit at its own granularity. Reading the current output bottom-up:

  • Field-granular (1:1). <Type>Fetchers.<field>(env): one coordinate, one DataFetcher, total. FetcherEmitter binds exactly this. The resolve side genuinely is field-granular, so the R316 field model is correct here and stays.

  • Argument/input-granular (finer than, and driven by something other than, the field). A condition method is the clean example. ConditionGlueRenderer emits one glue method per (coordinate, table) command row, classes grouped per parent type; each is a pure function of the field’s typed argument values, returning a jOOQ Condition, its body shape (eq / in / row(...).eq for composite) driven by the input surface, not by the field. One coordinate mints a method whose identity and body are a function of the inputs the field merely carries. No per-field model can express that; the method can.

  • Type-granular (a fold). <Type>.$project(grouped, table, env): one method per table-bound type that folds in its own scalar/inline fields, recurses through nested types in the same method, and opts in the columns batched (table-sourced) children need projected. Many coordinates, one method.

  • Anchor-granular. lookup<X> / load<X> rows-methods: one per SELECT-launching coordinate.

  • Dedup-by-class / boundary helpers. createBean / createRecord / scatterByIdx / <field>OrderBy / <field>InputRows: emitted once per class, or per boundary they serve.

Granularity is heterogeneous on purpose; that heterogeneity is the content of the model. The leaf zoo is what you get from forcing one field-granular model to source all of these kinds at once. (Full table with code coordinates: Discovery thread E.)

The two library types are node kinds, not the top level

Graphitron bridges GraphQL-Java and jOOQ, and the two libraries' own types still name the two sides of the graph, but as node-kind attributes, not as the top-level structure:

  • graphql.schema.DataFetcher is the resolve-side node kind, and it is the field-granular one above: every coordinate emits exactly one, total. The "graphitron field" identity lives here, and only here.

  • org.jooq.QueryPart is not an emit target at codegen. The SQL projection is assembled at runtime from the client’s DataFetchingFieldSelectionSet inside $project; the QueryPart is the per-request value those methods produce, not a thing we generate. The SQL-side node kinds are the methods that emit QueryParts ($project, the rows-methods, the condition methods), at the granularities above.

So the model is one graph; the resolve/SQL split and the two library types are how its nodes are typed, not two parallel emit targets. (Why the QueryPart is runtime-only, with the emitter evidence: Discovery thread D.)

Normalization: the leaf zoo is a denormalized view

(The sections from here through "What dissolves" predate the method-graph sharpening and still say "QueryPart" for the SQL-side unit. Read it as shorthand for the method that emits that QueryPart at runtime, per the node-kinds section above; the normalization, natural-key, and anchor arguments are unchanged by the sharpening. Folding the wording through is part of the systematic pass.)

The leaf model is denormalized in textbook ways, and the pivot is its normalization:

  • CompositeColumnField / CompositeColumnReferenceField carried an arity-N columns list, a repeating group (a 1NF violation). Normalizing to atomic rows yields N single-column QueryParts under one coordinate. This is why composite "simplifies immensely": arity stops being a leaf dimension and becomes the count of projected QueryParts. (R508 merged the pair into the ColumnBackedField family, one carrier with columns 1..N; the per-column row normalization stays with this item, per What dissolves.)

  • The split leaf welds on the parent-key projection, a fact that functionally depends on the parent’s query, not on the child coordinate (a 3NF-style transitive dependency). Normalizing moves that QueryPart to the query it depends on.

Two levels of natural key

The normalized relations are joined on two key levels, and naming both is the commonality that ties the model together:

  • The model’s natural key is the schema coordinate (parentType, fieldName): the glue that says "this DataFetcher and these QueryParts are one SDL field." R316’s leaf-reconstruction table already files parentTypeName / name / location as "field identity, the envelope, not a dimension"; this pivot makes that seam structural.

  • The data’s natural keys (columns, PK, FK) are the join graph. A column identity links a projecting QueryPart to the DataFetcher that reads it back; a PK/FK correlation links a split child’s QueryParts to the parent query; a re-fetch’s PK self-correlation (source.pk = target.pk) is the degenerate case of that same join. Composite’s N columns, split’s FK, and re-fetch’s PK are therefore the same thing, database natural keys doing the linking, which is why those three leaves felt like one disease.

So graphitron is the lowering of a set of schema coordinates into a DataFetcher relation and a QueryPart relation, normalized, joined on the schema coordinate (model key) and on the database’s own keys (the query graph). The leaf zoo is the fully-denormalized materialized view of that.

The normalized schema: the coordinate and its facts

R316’s (source, operation, target) is common vocabulary that carries well, but it is a per-coordinate summary row: a denormalized join over several independent facts, with operation crammed into a single slot. The entity is the schema coordinate (parentType, fieldName); the model is the coordinate together with its facts, each its own functional dependency:

  • coordinate -> source, total, 1:1. The arrival fact: whether a source object arrives and how many, plus the parent shape. Read off the parent and the edge into the field, not off the field itself.

  • coordinate -> target, total, 1:1. The output fact: the field’s own output cardinality (the wrapper) and shape. Read off field.getType().

  • coordinate -> operation, 0..N. The operation set (the QueryPart-emitting methods, thread E’s SQL-side commands). operation is not one forced verb but a set: a single field can select and join and condition and paginate and orderBy at once.

  • coordinate -> reference, 0..1. The cross-table fact: present exactly when the field’s value lives off the parent’s own table (a different table, or a column in a different table). Authored (@reference) or inferred from the unique foreign key; it lowers to a join operation, with joinPath as its resolved form. Detailed below in The reference fact.

  • coordinate -> referencedTable, 0..1. The reference’s destination table, named in its own right. Present exactly when reference is. Not the same as source.table (a self-referential FK makes them coincide while a join is still minted). Detailed below in The reference fact.

  • coordinate -> resolvedTable, derived, 0..1. The catalog table the @field resolves against (a column’s owning table, or a nested field’s rooted table). A priority coalesce over three facts: referencedTable ?? source.table ?? target.table. Present for every field that touches a table, absent for record / service fields. Detailed below in The resolved table.

  • The same facts apply to input fields, keyed (coordinate, path) (the dotted path to the input field, a derived value key per Input coordinates), relative to the consuming output coordinate. Their facts roll up into the output coordinate’s operation set. Detailed below in Input coordinates.

  • Two further facts are read-side. Every fact above is build-side, constructing the query or operation. These two instead read a value back out of the object at env.getSource(): a type-level source object fact (the cast-target record shape) and a field-level accessor fact (a locator; the read’s encoding is not a separate transform but follows from the Column or node facts the locator points at). Detailed below in Reading the source object and Node facts.

source and target are two different facts, derived by two different walks (parent/edge versus the field’s type), and they vary independently: the same target List(Table) sits under a Root source or a Child source. Earlier drafts bundled them into one (coordinate, source, target) row and named it "the DataFetcher relation"; that let the DataFetcher, which is a view (a node kind, per the two library types are node kinds, not the top level), masquerade as the top-level relation. The honest form keeps the coordinate as the entity and source / target / operation as its facts. The DataFetcher is the view that joins source and target (and dispatches the operation set); the QueryPart-emitting methods are views over operations.

The three splits do not all have the same forcing function, and the spec should not pretend they do. operation had to split out: it is multi-valued, so one slot was a genuine 1NF / repeating-group fault. source and target are each single-valued and 1:1, so co-storing them was never a normalization violation; separating them is fact-independence (two FDs, two walks) and refusing to let a view name a relation, not a normal-form fix. Both separations are right; only `operation’s is forced by a normal form.

The corpus directive sits on the coordinate (the natural key), so it already annotates the right thing; its verdict generalizes from one triple to a source fact, a target fact, and a set of operation rows, each independently assertable.

operation is a set because its members are triggered by separate, walkable facts. Most are input-triggered, fired by an independent SDL or argument fact: a table-bound return type mints select, pagination args / a Connection mint paginate, @condition or filter inputs mint condition, @orderBy mints orderBy, @service mints serviceCall. One is relational: join is minted by the reference fact, that is, by the relationship between source and the value’s table rather than by an independent input (see The reference fact). The operation set is the union of these triggers. This is the schema-walk reading of the whole thesis: the leaf cross-product (Split x Lookup x Composite x ...) is what you get from collapsing independent operations into one slot, so they multiply into leaf variants; as a set they merely co-occur, and the cross-product dissolves additively. Composite falls out the same way: N columns are N (or one N-ary) select operations, arity gone as a coordinate dimension.

Normalization assigns each R316 axis to the fact that owns it (nothing in R316 is wrong, it just gets distributed); the read-by column names the view that consumes the fact:

R316 axis Owning fact Read by Notes

source (Root / OnlyChild / Child)

coordinate -> source

DataFetcher

invocation cardinality; Child dispatches through a DataLoader

target wrapper (Single / List)

coordinate -> target

DataFetcher

output cardinality; the resolve-side contract

target shape Column / Table

coordinate -> target

a select operation

projection kind (a jOOQ Field, a table projection); the fact lives on target, the operation reads it

target shape Record / Field

coordinate -> target

DataFetcher only

read off a Java object; zero operation rows

operation Fetch / Paginate / Lookup / Count / Facet / DML

coordinate -> operation

the operation’s own method

the SQL verbs, now set members

operation ServiceCall

coordinate -> operation

DataFetcher delegates to it

serviceCall; no SQL

operation Nest

(none: empty set)

DataFetcher only

a regroup with no SQL; the DataFetcher’s existence is the fact

composite arity

coordinate -> operation

the select operation

the number of projected columns, not a coordinate dimension

split / re-fetch / new-query

operation address

the addressed anchor’s query

which anchor’s SELECT the operation lands in

Two refinements the flat triple hid. First, target is consumed by two views: its wrapper (Single / List, the output cardinality) is read by the DataFetcher, its shape by the select operation. The fact itself stays whole on coordinate -> target (it is just the field’s type); the two views read the parts they need rather than the shape being duplicated onto the operation. Watch the embeddable-Column-as- Record case: a target shape becoming a child’s source shape is a coordinate-to-coordinate edge (the wrapper algebra), not an operation. Second, operation multiplicity is 0..N, not 1..N: a field that reads off an in-memory record (target shape Record / Field), and Nest, have a source and a target fact and an empty operation set; the DataFetcher’s existence is the fact, and a no-op operation row would buy nothing.

R316 slices 1-4 are the denormalized, singleton-row view (one coordinate, its source and target facts, at most one operation’s worth of facts) and stay valid as that projection. They are the empty-or-one case of the 0..N operation set.

The reference fact

A field whose value lives off the parent’s own table, reaching either a different table (a nested table field) or a column in a different table (a column-reference field), carries a reference fact. Same-table fields (a plain ColumnBackedField) carry none. So reference present is exactly the condition that a join operation exists: the value’s read-table differs from source.table.

Naming the fact resolves the "alters the source / alters the path" puzzle, because it alters neither source nor target. The model’s source is the arrival (the parent that reaches the resolver), and @reference never changes that: an A-row still arrives. What it relocates is the table the value is drawn from (the read-table), which defaults to the parent’s table. The puzzle was two senses of "source": the arrival (the model’s source, untouched) versus the value’s read-table (what reference moves).

A foreign-key traversal needs a destination table and a path; reference always supplies the traversal, and the two field kinds differ only in which the field’s other facts had already pinned:

  • Column target (ColumnBackedReferenceField): a scalar names no table, so reference supplies both. It moves the read-table off the parent onto the destination. This is what reads as "altering the source".

  • Table target (TableField and kin): the destination is already pinned by the nested type’s @table, so reference supplies only the path, disambiguating which FK route reaches it. This is what reads as "altering the path".

Both are one edge-alteration seen against different fixed endpoints: reference parametrizes the edge between the enclosing query and the value, never the endpoints.

The shape axis (Column vs Table) is independent of a second axis the reference also carries: direction. A reference is to-one when the foreign key sits on the parent’s side (a parent reference: each parent row points at one destination row) or to-many when it sits on the child’s side (a child reference: many destination rows point back at one parent). Direction sets the target wrapper by the same wrapper-from-direction rule table fields obey: to-one yields Single, to-many yields List. The two axes are orthogonal, a 2x2:

to-one (parent reference) to-many (child reference)

Column

Single(Column), e.g. film.originalLanguageName

List(Column), e.g. film.actorNames: [String]

Table

Single(Table), e.g. film.language

List(Table), e.g. actor.films

Today’s ColumnBackedReferenceField is only the top-left corner (OutputField.single(Column), ChildField.java:140); List(Column) is the missing corner, a list of one scalar drawn from the to-many child rows. With it, column-ref and table-ref differ only in target.shape, and Single / List differ only in direction; resolvedTable is the destination table B in every cell. A List(Column) child reference is not a cheap scalar variant: being to-many it needs the same machinery as a to-many table field (a Child source, an anchor, a rows-method, batched or aggregated), projecting one column instead of $project. It is "a to-many table field minus the nested projection."

reference is authored or inferred, and the inference is total with a typed failure. @reference supplies it explicitly. Absent that, it is inferred from the foreign keys between source.table and the destination: exactly one FK and the path is inferred; zero or more than one and it is not derivable, which is an AuthorError telling the author to supply @reference with the information needed to join (the LSP-surfaced rejection, not a silent guess).

It lowers to the join operation; joinPath (List<JoinStep>) is its resolved form, never an independent axis. For a column-reference field it lowers into two places at once: the target’s column identity (the destination column) and the `join’s path, and those must agree (the column’s table is the join’s destination). That agreement is a referential-integrity check between the `target fact and the join operation, the FK-as-join-graph point made concrete.

The worked example is the additive proof, visible in the leaf records. ColumnBackedField (ChildField.java:227) and ColumnBackedReferenceField (:277) are component-identical except the reference variant adds joinPath (and parentCorrelation): same source (Table), same target (Single(Column)), same column and compaction. They are one (source, target) pair whose operation sets differ by exactly one join minted by the reference fact: {select} versus {join, select}. Not two leaf types: the same coordinate facts plus one more.

The reference’s destination has a name of its own: referencedTable, a 0..1 fact present exactly when reference is, and the table joinPath terminates at. It is not source.table: a self-referential FK (employee.manager_id -> employee) makes the two coincide while reference is still present, so a join is still minted. The join is minted by reference presence, never by referencedTable != source.table. Comparing the tables would silently drop self-joins, so the model must not "optimize" the join away that way.

The resolved table

coordinate -> resolvedTable is a derived fact: the catalog table a @field resolves against, the column’s owning table for a column field or the rooted table for a nested field. It is a priority coalesce over three facts, each arm defined exactly where it fires:

resolvedTable = referencedTable ?? source.table ?? target.table
  • referencedTable first: the value is reachable only by a join (a column-reference field, or a child / nested table field reached by an FK). For a child table field it shadows source.table and equals target.table.

  • else source.table: the value lives on the parent’s own row. This covers a plain column field, and a nesting type: an object type that is not table-bound, whose field inherits the parent’s table and shares its row(s). That is the Nest case: still SQL-backed, no join, no referencedTable, and distinct from the Record / Field shapes that read off a Java object.

  • else target.table: a root table field (source = Root). It has no source to reference from, so it carries no referencedTable and enters via the FROM clause. target.table is defined only when target.shape == Table, so this arm can fire only for root table fields.

It is present for every field that touches a table and absent only for Record / Field / serviceCall fields that never do. Root and nesting fields are mirror fall-outs of referencedTable being 0..1: a root table field takes resolvedTable from the target side (no source to reference), a nesting field from the source side (same table as the parent), and in both referencedTable is simply absent. For a column field target’s shape (Column) names no table, so `resolvedTable is the only carrier of it. It is the generalization of target.table to the scalar case, which is why it must be derived rather than read off one fact.

For a table field resolvedTable == target.table always, and whenever a referencedTable is present it equals them too. The three-way resolvedTable == referencedTable == target.table is the with-reference (child / nested) reading, and a root table field simply drops the middle term. Read over present facts, that coincidence is an invariant and a cross-check between two independently-walked facts: the FK route’s destination (referencedTable) and the declared output type (target.table). They are walked from different places, the foreign-key graph and the SDL return type, and must agree. A mismatch is an AuthorError ("`@reference` routes to X but the field returns Y"), not a silently accepted mismatch.

Naming it lifts a derivation otherwise recovered three ways (today source.table for ColumnBackedField, the joinPath terminus for ColumnBackedReferenceField, target.table for table fields; ColumnRef deliberately omits the table because this fact owns it). Consumers then read one fact instead of each reconstructing it:

  • the join operation’s destination is resolvedTable (when reference is present);

  • the select operation projects from resolvedTable;

  • a field’s resolvedTable is its children’s source.table: the table-level form of the wrapper algebra (a field’s target shape becomes its children’s source shape), and the actual carrier of the parent-to-child table flow.

The table expression

resolvedTable is the table’s class: its columns, its record shape, what $project projects. How a table node enters the query is a separate fact, tableExpr, the node’s materialization:

tableExpr(node) → arm, the materialization of a table node (the projected node, or any join target): - Catalog (the default): the static generated reference (Tables.FILM). No payload, derivable from the node’s table class. - RoutineCall(routine, argBindings) (@routine): a jOOQ table-valued function, parameterized by the field’s GraphQL arguments, producing an FK-less result table.

A RoutineCall node is FK-less; it can be the projected node (the routine result is the field’s table, a function-backed query) or a join target / root entry (see The join path), the only constraint being that joins adjacent to it are keyed by name-match or a condition, never an FK.

Every table node has a tableExpr, and the absence of a directive is the Catalog arm, not a missing fact (the same shape as source and target, total facts with arms). So the table-identity question (resolvedTable, the class) and the table-rendering question (tableExpr, the expression) split cleanly: resolvedTable drives projection, the source-object record shape, and the locator reads, all class-level and indifferent to the arm; only the FROM clause reads tableExpr. The argBindings on the RoutineCall arm are the field’s GraphQL arguments feeding the call’s parameters, the same input-coordinate / argMapping machinery @service and @condition use.

Composition with reference. The two are orthogonal and meet only at resolvedTable’s class. `reference owns the path and ON predicate (parent columns + the class’s FK metadata), terminating at resolvedTable; tableExpr renders resolvedTable wherever it enters, the FROM at a root field, or the terminal join step’s table at a child. Disjoint inputs (FK / parent columns versus field arguments), disjoint pieces of the query. The composition is a referential-integrity constraint of the same family as `resolvedTable’s coincidence invariant:

the reference path’s terminus class == the tableExpr call’s return class == resolvedTable.

Because it holds, the reference’s FK columns resolve on the call’s result exactly as on the static table, and the terminal join step renders its table via tableExpr instead of Tables.X. At a root field there is no reference, and tableExpr is the FROM directly. (A DTO parent reaches a child through a DataLoader-keyed batch lifted from the parent object, sourceKey / parentCorrelation, rather than a parent-table join; that is the @sourceRow machinery, gap 5, orthogonal to tableExpr itself.)

@routine materializes a node via RoutineCall. That node can be the projected terminus (the routine result is the field’s type, a function-backed query) or a join target reached by an FK-less join. The join mechanics live in The join path, next.

The join path

reference’s resolved form, `joinPath, is the linearized join graph: a start node followed by an ordered list of join steps reaching the projected terminus (resolvedTable). Walking the current parsePath pins four rules, three verified against the code and one sharpened:

  • The start node is source.table. path[0] joins from the source, so path[0]’s table is the second table in the sequence; `source supplies the first.

  • The terminus equals target. The last step’s target is resolvedTable (the referencedTable == target.table invariant, restated); callers assert it.

  • table: is key: with the derived FK. A {table:} element finds the unique FK between the current source and the named table and builds the same step a {key:} (named FK) would; non-unique is an AuthorError.

  • Root is permitted iff the first element is a routine. The path normally starts from source.table, which a root field lacks; a routine is materialized from its own args (source-independent), so it can supply the start the root field has no source for. A key: / table: / condition: first element joins from a source and stays root-invalid. So a root @reference reads FROM routine(args) → … → target, the routine the entry, the projected target the terminus, never the routine itself.

A join step is two orthogonal facts: a target and an on.

JoinStep(coordinate, stepIndex): - target: a table node materialized by tableExpr (Catalog | RoutineCall). - on (0..1): ColumnPairs(List<(sourceCol, targetCol)>) | Predicate(MethodRef). Absent only for the start node (a root routine entry, or the implicit source.table); present for every joining step, and then exactly one of the two.

The authored evidence resolves into on:

  • key:ColumnPairs, derived from a named FK when both sides are catalog tables (or the unique FK, the table: sugar), or from the target’s PK (default) or a named UK matched by column name when the target is FK-less (a routine).

  • condition:Predicate; the author owns the ON.

  • key: xor condition: (both is an AuthorError); a routine target with neither defaults to the PK name-match, a catalog target to the derived FK.

Two consequences of FK-less targets:

  • The hop out of a routine is non-FK. A routine result carries no FK, so the step after a routine carries a name-matched key: or a condition:, never an FK; the FK-less-ness propagates one hop forward.

  • Name-match carries an integrity check: the routine’s result columns must expose the key’s columns by name, the join-domain analogue of the enum comparability check, verified at build, not a runtime surprise.

The source side has a provenance too, the dual of the target arms. The first on’s source-side columns normally come from `source.table, the parent’s own row. When the parent is a class-backed DTO with no table (a @service return, a POJO or Java record), there are no parent columns to read, so @sourceRow supplies an authored Java method that lifts the key tuple out of the DTO. The source-side key thus has its own provenance, gated by the source object shape (Reading the source object):

source-side key parent provenance

RecordColumns

jOOQ-backed (a record / source.table)

inferred (catalog)

Lift(lifterRef)

class-backed (a DTO)

authored (@sourceRow, un-inferable)

Lift changes only where the source-side values come from, not the path or the evidence: the lifted columns are real catalog columns (the first FK hop’s source-side columns, or the leaf PK when there is no @reference), so the FK chain navigates from them exactly as for a jOOQ parent, and the resolver reuses parsePath with a null start (SourceRowDirectiveResolver.java:278). The lift’s RowN arity and column types must equal that derived tuple, the same integrity-check family. The no-@reference case is the trivial path where the lifted tuple is the leaf PK (fetch the child directly); @reference present walks the FK chain from the lifted columns. Unlike @enum, @sourceRow cannot be retired, a DTO’s key extraction is opaque Java, not catalog-inferable, so the directive stays as the authoring surface while the model absorbs it as the Lift arm, the mirror of the RoutineCall target arm.

So the @reference path is the join graph. The old flat JoinStep variants conflated the two axes (FkJoin = Catalog target + ColumnPairs from FK; ConditionJoin = target + Predicate); R438 shipped the decomposition as JoinStep.Hop(TableExpr target, On on) and deleted the flat variants, and R435 added On.Lateral and the name-matched key for hops adjacent to a routine result, so this part of the model is now the code’s shape, not a proposal. The R314 residue this section carried (migrating the inline @tableMethod leaf onto a MethodCall target arm) is discharged by removal: the directive, both leaves, and the MethodCall arm are gone, and the seal is Catalog | RoutineCall. New capability is a new target arm (RoutineCall), a new source-side provenance (Lift), or a new on derivation (PK/UK name-match), not a new step type. A routine node can sit anywhere: the projected terminus (its result is the field’s type, a function-backed query), an intermediate, or the root start; the only constraint is that joins adjacent to it are FK-less (a name-matched key: or a condition:). The @oneOf SDL surface for the path element (target table | routine, on key | condition) is the deferred follow-on.

Conditions key on the resolved table

A @condition is a predicate, and predicates attach to relations, not to projections; the relation a field reads is its resolvedTable. So a condition operation keys on resolvedTable, not on target. The two choices coincide everywhere a condition is legal today, because resolvedTable == target.table for table fields, so this is the normalized statement of the current behavior (filters live on TableTargetField today, ChildField.java:422), not a change to it; it merely extends cleanly to the cases where target is a scalar that names no table.

condition is a 0..N relation, owned by the coordinate and placed on its resolvedTable: the coordinate fixes which conditions exist (the same table resolved at two coordinates carries different ones), resolvedTable is where each predicate lands. The rows conjoin (AND) into the WHERE, or into a LEFT JOIN ON clause for the Single value-gating case below. Each row has a provenance:

  • authored: an @condition, an opaque jOOQ predicate the model knows only by method name.

  • generated: minted by an input table binding, the input-coordinate fact lowered (see Input coordinates), structured as a column of resolvedTable, an input source, an operator, and presence-gating.

The two provenances mirror the reference fact’s authored-or-inferred shape: @condition is to a generated condition what @reference is to an inferred join. The discovery thread’s GeneratedConditionFilter (the body shape eq / in / row(...).eq "driven by the input surface, not by the field") is exactly the generated arm’s resolved form, no longer a loose observation.

The condition’s semantic forks on target.wrapper, not on target.shape:

  • List (to-many) is row-set filtering, choosing which rows of resolvedTable contribute. Standard, no parent-cardinality hazard (the set is already per-parent, batched or aggregated). True identically for List(Column) and List(Table). This is why allowing child references makes conditions obviously sensible: a List(Column) child reference has a real relation to filter.

  • Single (to-one) is value-gating, nulling the value when the predicate fails. Correct only with the predicate in the join’s ON clause under a LEFT JOIN, so a failing predicate nulls the value rather than dropping the parent row. This subtlety is a property of the Single wrapper, shared by Single(Table) and Single(Column) alike. It was never a column-reference quirk.

So conditions over resolvedTable are first-class for every wrapper. The only open semantic is Single value-gating (the ON-clause placement and the parent-cardinality-preserved invariant), and it is owed for to-one table references regardless, so it is not new debt introduced by allowing column references.

Presence-gating is a third, orthogonal gate, carried only by the generated arm: an optional input absent emits nothing (TRUE), present emits column OP value. It governs whether a predicate fires (read from the input’s nullability). The wrapper fork governs how a fired predicate applies. A single generated condition on a Single field is therefore both presence-gated (does it fire?) and value-gating (if it fires, it nulls rather than drops). Authored conditions carry no presence-gating; the author expresses their own.

Input coordinates

@reference and @condition apply to input fields too, so input fields are fact-bearers on the same footing as output fields. The key splits along the authored / derived line The natural keys draws: authored facts (@field, @reference, @condition on an input field) live at the input type’s own member coordinate, the definition site where the editor edits them, while use-site resolution is keyed by the occurrence path: the consuming output coordinate plus the traversal to the input field, rooted at the field’s argument list (where.title, filter.actor.lastName). The path is not a coordinate and not a stored key; it is a derived, value-keyed relation (the transitive closure of argument-use over input-object field edges, the serialized path as the parent key with ordinal step rows carrying the same data relationally, per R589). Use-site facts key on it, not on the GraphQL input type, for the same reason output facts key on the coordinate: the same where input resolves against film at one query and actor at another, so its bindings, inferred FKs, and referencedTable all depend on the use site.

An input coordinate carries the same fact vocabulary, source, target (shape × wrapper), reference, referencedTable, resolvedTable, and obeys the same nesting algebra: a path-internal input object is Table-shaped and its resolvedTable becomes its children’s source.table, while the leaves are Column-shaped and are the actual predicates. The input tree is a coordinate tree with the same facts, flowing toward a predicate rather than a projection.

Input facts roll up into the output coordinate’s operation set (the output coordinate is the query emitter):

  • an input coordinate’s reference ⇒ a join on the output query. This is why input-side @reference is in scope: a cross-table input filter is just the reference fact doing on the input side what it does on the output side.

  • an input coordinate’s leaf target (a column of its resolvedTable) ⇒ a generated condition, with operator from the input target.wrapper (Singleeq, Listin, multi-column path ⇒ row(...).eq) and presence-gating from the input’s nullability.

So an input field is a shared fact source, and the field-to-operation relation is many-to-many, not 1:1. The same field can mint a generated condition and be consumed as an argument by an authored @condition. Both are live and conjoin. A flat triple forces each field into a single role and cannot express this; the normalized model lets one field carry several facts. The raw relations are:

  • generated_condition(coordinate, path), minted by a leaf binding.

  • authored_condition(coordinate, method, override), an @condition.

  • consumes(coordinate, method, path), which input fields the authored condition takes as arguments (read off its parameter list).

The resolved operation set is union-then-suppress, not a plain union. @condition(override: true) is a suppression edge: for the path it consumes it blankets that path and its whole subtree of generated operations, the generated conditions and the join`s that input-side references in the subtree minted to serve them. (We start by reaping the entire generated subtree and will narrow only if a use case needs the join to stand for a hand-written predicate.) Authored `@condition facts are never suppressed; only auto-generated scaffolding is:

generated_op(c, p) is live iff
  ¬∃ m, P. authored_condition(c, m, override=true) ∧ consumes(c, m, P) ∧ P ⊑ p

conditions = authored_conditions ∪ { live generated conditions }

where P ⊑ p means P == p or P is an ancestor of p in the dotted-path tree. A generated op is suppressed iff consumed by at least one override condition. An override: true on a condition that consumes nothing is a no-op. The suppression is the same shape of declarative resolution as the resolvedTable coalesce: a function over the raw facts, computed once, not a special case threaded through emission. Under the store the prefix predicate P ⊑ p is a join over the occurrence-path step rows, which is exactly the evaluation site R589 gives the unbound-cascade rule; the algebra above is unchanged, only its key stops pretending to be stored.

Reading the source object

Every fact above is build-side: it constructs the SELECT, the joins, the conditions, the operation. But every output field is wired to exactly one DataFetcher, and a DataFetcher ultimately returns a value to graphql-java. Some fetchers obtain that value by producing it (running the operation: a query, a @service call, a DML write). Others obtain it by reading it out of the object already at env.getSource(). The read is its own fact family, the read-side complement of the build-side schema, and it is what this section names.

Two phases, consume then produce. "Producer" and "reader" are not disjoint field sets. They are two phases inside one fetcher, in a fixed order:

  • consume: read this field’s source object (the object its parent deposited at env.getSource()).

  • produce: run the operation, depositing a new object.

  • the deposited object is the source object this field’s children consume.

A pure reader is the degenerate case with no produce phase: the consumed value is the answer. A root producer is the opposite: no source arrives. Everything else does both, and the order is load-bearing: a re-fetch reads the parent row’s foreign key (consume) before it launches the child SELECT keyed on it (produce). So one field touches two source-object facts about two different types: its parent type’s (consumed) and its return type’s (produced, which is its children’s source object).

This also dissolves a tension from the leaf walk: a bare column read and a bare scalar read both carry operation = Fetch, and the catalog-versus-Java split rode entirely on sourceShape. That is because sourceShape is not a sibling of operation at all. It is read-side. operation is the build-side verb (how to produce a value); the source-object shape is the read-side fact (how to read one). They sat in one list but belong to different families.

The source object is type-level

GraphitronType is "the authoritative source of source context for all fields defined on it." The source object is therefore a type-level fact: every field on a type reads off the same kind of object, fixed by the type’s classification, not the field. The field-level sourceShape() / domainReturnType() are projections, cross-checked against the type (SourceShapeProjectionTest) so a field cannot diverge from the type it claims.

Its value is a record shape, and never a table. A table is a build-side relation; what arrives at env.getSource() is always a row / record (or a scalar, or a Java object), never a relation. So "Table" is not one of its values. The arms are the cast targets the read needs:

  • a jOOQ record source casts to the generic org.jooq.Record. Reads go through get(Field<T>), so the concrete FilmRecord is never needed, and the typed-vs-sparse TableRecord / Record distinction is a producer concern.

  • a Java source casts to its backing class (FilmDto).

  • a scalar is "already the value," no cast.

DomainReturnType (Record / TableRecord / Plain, no Table arm) is the carrier. SourceShape (Table / Record) is not, because it fuses three things the read keeps apart: record-ness, catalog provenance, and table-boundness.

Table-boundness is a separate fact. For a table-bound type the record shape is derivable from the TableRef, but the source-object fact carries it materialized, so a reader consumes one fact rather than walking "table-bound ⟹ jOOQ record ⟹ read by Field." This is the resolvedTable lift one level up (derive once at classify time, store it, never re-derive at the read site). The build side keeps the TableRef, the read side keeps the record shape, and provenance is consulted by neither.

The uniform-producer axiom. Different fields can produce the same SDL type (a @table type reached by a SELECT and by a @service). We assert all producers of a type deposit the same shape. Disagreement is an AuthorError (the shipped validateUniformDomainReturnType / MultiProducerDomainTypeDisagreement guard). This is the precondition that lets the fact be type-level: one shape per type means the child reads against one known shape, the cast is unconditional, and the accessor stays monomorphic. Drop it and the source object becomes a (type × producer) fact and every read turns polymorphic. Deferred: producer- polymorphism is named but out of scope, the same "assert the simplifying invariant now" move as override-suppression-maximal and the List(Column) deferral.

(NestingType is the transparent exception: it owns no table, inheriting the embedding type’s row, so the same nested type under Film versus Actor sees FilmRecord versus ActorRecord. The fact stays type-level, owned by the embedding TableBackedType. The model copes by reading nesting children by name off the generic org.jooq.Record, the identity all embedding sites share.)

The accessor is field-level

Where the source object keys on the type, the accessor keys on the field: each field pulls its own value out of the (now cast) source object. It is a sealed family, each arm carrying only its own facts, gated by the source object, replacing the nullable column-xor-accessor slot pair the pre-merge record-read leaves carried (dissolved into RecordReadField’s sealed `ValueLocator) with arm identity. It is a locator. There is no transform axis: how a read is encoded is not a function carried on the field but a consequence of the facts the locator points at, worked through after the locator arms below.

The locator says where the raw value(s) live, one leaf read or N for a composite:

  • typed jOOQ field: the FQN of the Field<T> constant to extract. Provenance-blind: a jOOQ-generated FILM.TITLE and a graphitron-generated field read identically via record.get(thatField), which collapses the present ColumnBackedField-read and ComputedField / @externalField-read into one arm and retires the ColumnRef-omits-its-table awkwardness (the accessor holds the table-qualified reference, so the read needs no table fact).

  • Java record component / POJO getter / public-field read: the resolved Java accessor (today’s AccessorResolution.Resolved).

  • by-name jOOQ field (DSL.field("title")): the untyped fallback when no constant resolved (the nesting-reuse case).

  • whole-object passthrough (env -> env.getSource()): the value is the source object (the NestingField identity read).

  • localContext / Outcome.ErrorList arm: where an errors list lives (today’s ErrorsField.Transport).

There is no transform fact; what looked like one dissolves into facts that already exist. Earlier drafts paired the locator with a transform (Direct / NodeIdEncode / EnumValueOf / JooqConvert), copied from today’s CallSiteCompaction / CallSiteExtraction arms. None of those is a function the coordinate carries:

  • Direct is identity: the absence of any conversion fact, a bare read.

  • EnumValueOf and JooqConvert are Column facts, not transforms. The wire→SDL coercion is graphql-java’s, type-level (an ID arrives as String, an enum literal as its registered value); the SDL→storage step is entailed by the column’s own Java type / DataType. An enum’s backing type is uniform across its use sites, so the conversion lifts to the graphql-java boundary and the read is Direct (see Enum facts); ID’s backing is per-column, so it cannot lift type-level and the bind goes through the column’s `DataType, again driven by the column fact, not a carried function. (Generalizing this off the ID-only trigger is R261.)

  • NodeIdEncode / decode is node facts (Node facts, below): the codec is entailed by a node’s key definition, nothing the field carries.

So the read side is a locator plus references to Column and node facts; the compaction / leafTransform slots on today’s carriers (ColumnBackedField.compaction, ValueShape.Scalar.leafTransform) are the conflation the normalized model takes apart, the locator is column / sdlPath, and NodeId* routes to node facts while Direct / EnumValueOf / JooqConvert carry no read-time step at all. A composite key is not a composite transform but an N-read locator feeding one node codec: the N-column repeating group becomes N source columns under one node key, arity gone as a leaf dimension, dissolving CompositeColumnField / CompositeColumnReferenceField on the read side the same way the spec dissolves composite on the projection side.

Composition

A read is cast then access: the source-object fact emits the unconditional conversion of env.getSource(), the accessor reads off the converted object. The source object gates the legal locator arms (a jOOQ-record source admits the typed-field and by-name arms, a Java source the component / getter / field arms, and passthrough is shape-agnostic). So the read side is two facts, a type-level source object (the cast target) and a field-level accessor (a gated locator), with any encoding entailed by the Column or node facts the locator points at rather than carried as a third thing. They stand as the read-side complement of the build-side source / target / operation / reference / resolvedTable / condition family.

Node facts

@nodeId looked like a transform (a function applied to a read) but it is not: there is no function to carry. encode/decode is entailed by a node’s identity definition, the way joinPath is entailed by a reference. Given a node type and its key columns the codec is fully determined; the coordinate does not have an encode step, it references a node, and the node’s facts are the codec. So @nodeId is the residue that made "transform" look like an axis, and naming it as node facts is what lets the axis go.

A node is a GraphQL object type carrying @node. Its identity is the type-level pair:

Relation PK Attributes

NodeType

type

typeId, table

NodeKeyColumn

(type, ordinal)

column

type is the object type, the natural key @nodeId(typeName:) references. typeId is the user-providable wire discriminator (@node(typeId:), defaulting to the type name, so total and non-null) stamped into the encoded id. table is the backing table the key columns live on. NodeKeyColumn is the ordered key (@node(keyColumns:), defaulting to the primary key); the repeating group is its own relation per 1NF. Together they define encode (read the columns in ordinal order, stamp typeId, base64) and decode (the inverse); the codec is a view over them, not a stored function, and is direction-agnostic, the coordinate’s polarity picks encode or decode.

A coordinate’s id read projects the node key onto its row. A @nodeId coordinate names a node type (explicit typeName: or the deduction rule), so type is the coordinate’s own fact, reached through the coordinate, not repeated per projection row. What the coordinate must resolve is which local columns carry that node’s key:

Relation PK Attributes

NodeKeyProjection

(coordinate, ordinal)

sourceColumn

InputNodeKeyProjection

(coordinate, path, ordinal)

column

NodeKeyProjection is the read-side locator for an output id: the source-table column carrying each key position, ordinal aligning to the coordinate’s NodeKeyColumn. InputNodeKeyProjection is its input-side sibling, the same payload (a column per ordinal) at a different grain, an input field is keyed (coordinate, path) (the dotted path within the consuming output coordinate’s argument structure, per Input coordinates), so it cannot share the output relation without forcing a null path (no-null discipline). Both are present exactly when the key projects, all-N-ordinals-or-none, since a half-projected key is useless to the codec.

Whether the key projects is one derived fact, not a stored boolean. The projection is derived by composing foreign-key column pairings along the path from the source table to the node’s table:

Relation (catalog) PK Attributes

ForeignKey

fk

childTable, parentTable

ForeignKeyColumn

(fk, ordinal)

childColumn, parentColumn

The path is resolved exactly as the reference fact resolves its joinPath: the unique foreign-key chain is inferred, @reference disambiguates, ambiguity-without-@reference is an AuthorError. With the path in hand each NodeKeyColumn(type, i) projects to a source column by following the pairing back through the chain, aligning on parent-column identity, not raw FK ordinal (the FK’s own order need not equal the node-key order). The composition is total, so the projection exists, exactly when the path is identity-carrying:

  • one hop is always identity-carrying: an FK’s child columns hold the referenced key by definition, so the parent’s identity is already in the source row.

  • multi-hop is identity-carrying iff the containment closure holds: at every intermediate table the outgoing FK’s child columns sit inside the key the incoming FK targets, so each ancestor’s key is embedded in the next descendant’s. This is the identifying relationship (the FK is part of the key), and chaining it lets one source row carry the keys of ancestors several hops away.

So identityCarrying is not stored, it is the presence of the projection (∃ NodeKeyProjection(coordinate, )). When present, the id read is pure column projection, *no operation minted, entirely read-side. When absent the far key is not in the row, so we lack the fact and must use the other strategy: keep the resolved path and mint build-side SQL, a join to read the key for an output id, an EXISTS / semijoin to constrain it for an input filter. A non-projectable node id is just a reference plus a codec, riding the reference fact’s join machinery. (Deferred: the partial-projection hybrid, some key columns local and some joined, is collapsed to all-or-none for now.)

One integrity constraint binds the halves: on the terminating hop the set of ForeignKeyColumn(fk, *).parentColumn must equal NodeKeyColumn(type, *).column, the path targets exactly the node’s key. This is the directive’s own "the foreign key must match the key defined in the referenced type’s @node-configuration", now a typed referential-integrity check rather than a runtime assumption, the same discipline as the column’s-table-equals-the-join’s-destination check on the reference fact.

Enum facts

An enum is a scalar whose backing type is uniform across its use sites, and that one property is what sets it apart from ID. ID is a single scalar shared by columns of many Java types, so its conversion is per-column (JooqConvert, the accessor is field-level above); an SDL enum maps to one backing type everywhere it is used, so its conversion lifts to the graphql-java boundary, runs once at schema synthesis, and the read is Direct. No per-coordinate transform reappears.

Like a node id, an enum does not bind to a column directly. The column comes from the coordinate’s own facts, an output field’s resolvedTable + @field / reference, or, when the enum is an argument or input-object field, the input-coordinate facts keyed (coordinate, path) relative to the consuming output field. The enum contributes only its value set; the column binding is the shared leaf machinery, mediated by the output field for inputs exactly as the rest of the input facts are.

So the enum’s own facts are authored, and everything about its storage form is derived:

Relation PK Attributes Provenance

EnumType

name

authored (SDL)

EnumValue

(enumType, sdlName)

runtimeValue

authored (SDL)

EnumBacking

enumType

backingType

derived (roll-up of producer javaType)

EnumConstant

(javaClass, name)

catalog, only when backingType is a Java enum

runtimeValue is the @field(name:) form, total, defaulting to sdlName. EnumBacking is the roll-up of the Java types of the producers the enum’s coordinates resolve (a column javaType, a @service signature type, an accessor return type), the same producer-reflection that retired @record. It is total for a reachable enum and its backingType is one of:

  • a Java enum (a Postgres-enum column, or a Java-enum service type),

  • String (a varchar column; the former "text-mapped" case, now just one backing),

  • a numeric type (an integer column).

The model retires @enum with it: the backing is inferred authoritatively from the producer, so an authored class can only contradict the truth, the same reasoning that retired @record. The directive will stay declared for the parser; the classifier will reject any application with a migration message. (This is the model decision; the directive is still honored by today’s classifier, and the code retirement is filed as R360, Backlog.)

The lift renders the value into the backing type once, at synthesis. Because backingType is uniform, schema synthesis registers, per EnumValue, the runtimeValue rendered into backingType as the graphql-java enum value:

  • Java enum → E.valueOf(runtimeValue)

  • numeric → runtimeValue parsed to the numeric type

  • String → runtimeValue itself

graphql-java then matches that object in both directions (GraphQLEnumType maps a name to any Object; the round-trip is pinned for the String case by EnumSerializationExecutionTest and the matching is object-generic), so the input arrives already typed and the output column value serializes by equality. The read is Direct for every backing.

Two constraints carry the soundness, both relational rather than reflective at the read site:

  • Convertibility: each runtimeValue is valid in backingType, the EnumConstant name match for a Java enum, parseability for a numeric, trivial for String. This generalizes the comparability check; only the Java-enum case needs EnumConstant.

  • One enum, one type: every producer the enum resolves to has the same backingType, which licenses the type-level roll-up and the single registered value. Two use sites resolving to different backings is a genuine schema error (a typed rejection), not a config artifact.

So enum handling is the enum’s authored value set (EnumType / EnumValue) plus a derived, total EnumBacking over the producer types, riding the shared column-binding machinery, with convertibility and one-enum-one-type as the checks and the conversion lifted to a synthesis-time rendering. There is no EnumValueOf fact and no @enum directive.

Discrimination

An interface or union field returns a value whose concrete type must be recovered before any field is read; that recovery is graphql-java’s TypeResolver. It is the read-side dual of the accessor: the accessor reads a value off a concrete type, discrimination recovers the type of a polymorphic one. It does not break the monomorphic source-object axiom. The type is recovered first, then the concrete type’s fields read off the concrete type’s own record, and each concrete type is monomorphic; the interface/union itself reads no fields. So there is no conditional cast, only a type-recovery read.

The fact is a discriminator whose signal is one of two things, and which one applies is forced, not chosen:

  • RecordClass: the runtime jOOQ record’s Java class is the type. Available when the concrete record survives with its type intact (a @service-returned TableRecord, a record-backed parent’s hub record) and participants map to distinct tables, hence distinct record classes. No discriminator column, no projection.

  • DiscriminatorColumn(column, value → type): a column value names the type. Forced in exactly the two cases the record class cannot serve:

  • same-table participants (@discriminate(on:) + @discriminator(value:)): all share one record class, so the class cannot tell them apart, the discriminator column’s value does.

  • erased class (a multitable read that UNION ALL`s participant tables into one projection): the union throws the Java type away, so a synthesized `__typename literal is projected per branch purely to recover it. The synthesized discriminator exists only to undo that erasure; where the class survives, none is needed.

Discriminability is the integrity check. Every participant must be distinguishable by some signal: distinct tables resolve by class, same-table participants need a discriminator, and same-table participants with no discriminator are a build error, not a silent misdispatch (a returned record, or a UNION branch, would match two arms and tag rows with both types). This is one invariant shared by every polymorphic path, the read side that recovers the type and the build side that produces the rows.

So discrimination is a read-side type-recovery fact with a two-arm signal (RecordClass | DiscriminatorColumn), the arm forced by whether the concrete type survives, collides, or is erased, gated by the discriminability invariant.

Two signal domains: rows and exceptions

Everything above is the row domain: the polymorphic value is a SQL row and the signal reads off it. The same type-recovery fact governs a second domain, the exception domain, where the polymorphic value is a caught Throwable and the recovered concrete types are the @error types of a payload’s error union. This is what @error lowers to. An @error type is a source object like any other (its path / message / extra fields are accessors reading off the caught exception, which is why the extras must be reflectively readable off the handler’s exception class, the same readability check a DTO source object carries); which error type is the discrimination, dual of DiscriminatorColumn but keyed on an exception signal rather than a column value. The monomorphic axiom holds unchanged: the concrete error type is recovered first, then read monomorphically off the matched exception.

The exception-domain signal is a partition, not a cascade. The cell predicate generalizes from the row domain’s column = value to one of:

  • ExceptionClass(className → type) (the GENERIC handler): a thrown exception lands in this cell when it is instanceof className.

  • SqlState(state → type) / VendorCode(code → type) (the DATABASE handler): the `SQLException’s state / vendor code equals an exact scalar, the exception-domain analog of an exact discriminator column value, disjoint by construction.

  • Validation(→ type): the synthesized GraphQLError cell.

Three things keep this a partition rather than an authored-order cascade, none of which needs a priority ordinal:

  • An optional matches message-substring is a per-cell refinement filter, at most one per cell: a non-match falls through to the total complement, never to a sibling cell. matches only narrows a cell, it never splits one signal into two types (splitting the DATABASE domain is what the exact SqlState / VendorCode keys are for; splitting GENERIC by message is the fragile pattern this disallows).

  • Subtype overlap (a cell on IntegrityConstraintViolationException alongside one on SQLException) is resolved by a fixed most-specific-class-wins rule, a partial order on the handled classes, not by listing order. Well-defined as a build check: the handled exception classes form a tree under assignability; two unrelated classes both matching one thrown class is the only rejection.

  • Cause-chain depth (an outer wrapper and an inner cause landing in different cells) is resolved by the fixed outermost-first walk of getCause(), the same category of fixed-strategy realization as "read the discriminator column", not a per-coordinate fact.

The partition is total via a redact complement: a Throwable matching no cell is logged with a correlation id and surfaced as one generic error, the privacy contract. That totality is a fact (a reviewer-checkable invariant); the walk and the logging are realization. Referential integrity mirrors the row domain: a GENERIC className must resolve to a real Throwable subclass (the dual of "a join column must name a real catalog column"), and the discriminability invariant carries over as the disjointness build check above.

The signal partition recovers which error type. Where the recovered errors then go is a separate, operation-side fact, the error guard: an operation that can throw (DML / serviceCall / Lookup) carries errorGuard(channel, handlerSet), where channel is the transport arm (an outcome wrapper, a developer payload class with a bound errors slot, or a DML local-context sentinel) and handlerSet is the interned partition keyed by the reachable error-type set (interned because distinct coordinates sharing an error union share one emitted dispatch table). The guard is the one genuinely new sub-fact @error contributes; the recovery itself is discrimination, and the errors-field read is an accessor whose locator arm is "the errors list off the channel."

It is distinct from the deferred producer-polymorphism (many producers of one type disagreeing on shape); here one type’s concrete subtype varies per row (or per thrown exception), resolved before any read. The per-participant filter surface a polymorphic query carries (a WHERE lowered against each participant’s own table) is a separate axis, the condition / join-path model applied once per participant, not part of type recovery.

We are data modeling: the relational discipline, not a database engine

Migrated: the discipline’s executable form (typed jOOQ access generated from the store’s own DDL, the declared-FK versus detection-query split of referential integrity, the no-migrations lifecycle with compile time as the only compatibility surface) lives in docs/architecture/explanation/fact-model.adoc, section "The discipline has an executable form". The argument-by-argument reversal record of this section’s original "no engine" answer is preserved in the git history; the spike measurement stays citable at roadmap/audits/2026-08-05-fact-base-h2-spike.md.

What stays: the back half remains outside the store’s ambitions. The emit side is imperative JavaPoet rendering that no engine makes easier, so the store carries the classification (front) half and the command relations, and determinism is owned at the emission boundary (whatever crosses into emission or diagnostics output is sorted at the crossing). The salsa-style incremental-memoization architecture this section once reserved is the road not taken: capture reads the type-definition registry (the linear half of graphql-java, never the superlinear assembly), and the warm-start cache is the surviving concession to LSP startup latency.

Seam worklist (living table)

This is the working surface for the spec’s central open decision: which seams (named method-call edges) exist in the target lowering. Each row is one candidate node; the table is iterated as decisions land, and it is the denormalized roll-up of three things defined downstream in this doc, so read the columns against them: the naming regime is thread J (R1 = name minted once and read on both ends, R2 = formula reconstructed at each end), the seam verdict applies the seam-placement rule of thread K (a seam belongs where a unit is (a) chosen by a runtime strategy/dispatch, (b) reused across more than one caller, or (c) something we want to assert independently in the corpus or tests; inline only a linear, single-use, non-varying construction), and "folds into X" means the row is an arm-renderer that is part of node X, not a node of its own (thread K’s pair partition). The acceptance test for the finished table is thread I’s bidirectional closure invariant.

Rows 1 to 9 are the seams the generator already cuts (the migration baseline; full detail in Current seam topology below). Rows 10 to 11 are the decided new seams (the 2026-06-19 target topology of thread K; both landed in the R549 window). Rows 12 to 16 were the open surface: rows 12 to 14 closed as command data, rows 15 to 16 remain the fragments whose promotion-or-inline verdict is still open. This table is the back-half view of the coordinate’s operation relation; Operations are realized by seams below draws the member-to-seam crosswalk that wires the two together.

# Candidate node Today’s emitter Granularity Regime (J) Seam verdict (rule a/b/c) Naming target / open issue

1

<Type>Fetchers.<field>(env)

FetcherEmitter, DataLoaderFetcherEmitter

field, 1:1, total

R1 (the class suffix single-homed at GeneratedUnits.FETCHERS_SUFFIX, 2026-08-01 reading)

seam (a): picks root / child / service strategy

landed; the cross-class edges the fetcher bodies reference are rows of the fetcher-edge relation (FetcherEdgeCommands)

2

<Type>.$project(grouped, table, env)

ProjectionUnitRenderer (command-driven; landed 2026-07-29, R549 slice 3.1)

one method per projection unit: anchors, (anchor, typeName) nesting units, per-coordinate pivot units

R1 (unit addresses minted by GeneratedUnits, the call composed by render/ProjectionCall at every host)

seam (b, c): reused + assertable

landed; unmigrated launcher hosts read the same call emitter, so the literal has one home

3

rows<X> / load<X>

SplitRowsMethodEmitter, MultiTablePolymorphicEmitter

anchor (SELECT launcher)

R1

seam (a, b): batched/direct dispatch + reuse

settled (rowsMethodName)

4

scatter*ByIdx

SplitRowsMethodEmitter

dedup-by-class

R2

seam (b): class-level reuse

lift to R1

5

<field>Condition(...)

ConditionGlueRenderer (command-driven; landed 2026-07-29, R552)

one glue method per (coordinate, table) row, classes grouped per parent type

R1 (class and method refs minted by GeneratedUnits; every consumer emits the shared ConditionGlueCall)

seam (c): assertable

landed; the lift closed on both ends (the entity layer, its className()/methodName() naming facts, and the shim’s + "Condition" formula all retired)

6

join-path helper

JoinPathEmitter

per join path

R1

seam (b): reused

settled (MethodRef)

7

<field>InputRows

LookupRows (render core)

per lookup field

R1 (GeneratedUnits.inputRowsMethod is the single mint)

seam (c): assertable

landed; the root-host end lifted with R541 (Done)

8

create<Bean> / create<Record> / decode<Record>

InputBeanInstantiationEmitter, JooqRecordInstantiationEmitter

dedup-by-class

R2

seam (b): class-level reuse

lift to R1

9

<field>OrderBy

TypeFetcherGenerator (helper emit) + OrderByFragments (render core); the standalone generator retired in the R549 window

per orderable field

R1 (GeneratedUnits.orderByHelperMethod is the single mint, read at the launcher row’s Ordering.Helper arm and at the helper emit; the only + "OrderBy" in main source, 2026-08-01 reading)

seam (c): assertable

landed

10

Root Query unit (the root rows<X>-equivalent)

RootLauncherRenderer (command-driven; landed with R541, Done)

anchor

R1 (the launcher relation’s minted unit addresses)

seam (c): assertable; one unit kind across root and child, instances stay per-coordinate (verdict letter corrected 2026-07-26: root and child WHERE clauses differ, no literal sharing)

landed by R541 as a coordinate-keyed launcher command rather than as a named unit an emitter reads facts for; the root/child asymmetry is closed

11

Service-call unit

the launcher relation’s LaunchSource.ServiceCall / ServiceTableLift arms (render core ServiceRowsFragments; ServiceMethodCallEmitter renders the call fragment inside the unit)

per service-backed field

R1 (with the launcher family)

seam (a): service vs query strategy

landed in the R549 window as the service-backed arm of the same delegation

12

Inline column-reference arm

SelectTerm.ScalarSubselect / SelectTerm.Column (command data; emitter retired 2026-07-29)

term of a Contribution.Project

n/a

resolved: stays inline as a term, but assertable as data (the pipeline tier reads the produced term, no promotion needed)

closed

13

Inline table-field arm

CallWrap.Multiset (command data; emitter retired 2026-07-29)

wrap of a Contribution.Call

n/a

resolved: folds into Projection as data; the Y.$project edge is a typed UnitRef on the call

closed with row 2

14

Inline lookup table-field arm

CallWrap.LookupMultiset (command data; emitter retired 2026-07-29)

wrap of a Contribution.Call

n/a

resolved: folds into Projection as data, its rows helper a minted UnitMethodRef (row 7)

closed with row 13

15

Channel catch / early-return arms

ChannelCatchArmEmitter, ChannelEarlyReturnEmitter

arm of fetcher body

n/a

folds into Fetcher (row 1)

OPEN: assert the error channel independently?

16

ArgCall fragment

ArgCallEmitter

fragment

n/a

folds into Condition / Query unit

inline (linear, single-use) unless a caller reuses it

Operations are realized by seams: wiring the two halves

The two relational pictures in this spec are one model seen from its two ends, joined by the operation relation. The front half (the normalized schema above) keys facts on the coordinate and reads each operation off a trigger fact. The back half (the seam worklist just above, and the method-call graph of threads E to K) is the emitted side: named methods and the calls between them. A coordinate’s operation set is the set of QueryPart-emitting seams its query unit composes; the seam worklist is the back-half view of the operation relation, the same way the DataFetcher is the view that joins source and target.

The back-half seams sort into three layers, and only the middle one is the operation relation:

  • The DataFetcher view (worklist row 1). Reads the source and target facts and dispatches; it is a view over facts, not an operation. The nest-only coordinate (empty operation set) bottoms out here: the DataFetcher regroups in memory and emits no SQL seam.

  • The dispatch targets: the Query unit (the SELECT launcher; rows 3 and the decided root row 10) and the Service-call unit (row 11). The Query unit is the host the SQL operation set renders into; the Service-call unit is the serviceCall operation realized as a unit.

  • The operation seams the Query unit composes (one per operation-set member; the crosswalk below), plus the boundary helpers (scatter row 4, bean/record row 8) that marshal across the resolve/SQL boundary and, like the DataFetcher, are views not operations (which is why they carry no trigger fact).

The member-to-seam crosswalk (the column the worklist deferred to here):

operation member Trigger fact (front half) Realizing seam (worklist row) Naming regime

select (projection)

table-bound target

Projection $project (2)

R1 (landed 2026-07-29)

select (launch / FROM)

a source anchor

Query unit / rows-method (3; root 10)

R1 (root landed with R541)

paginate

pagination args / Connection

applied within the Query unit (3, 10)

with the query unit

join

the reference fact

join-path helper (6); lookup InputRows (7)

R1 (6, 7)

condition

@condition / filter inputs

Condition (5)

R1 (closed 2026-07-29, R552)

orderBy

@orderBy

OrderBy (9)

R1 (GeneratedUnits.orderByHelperMethod)

serviceCall

@service

Service-call unit (11)

R1 (the launcher family’s service arms)

nest (empty set)

non-table nesting

no seam; DataFetcher (1) regroups

n/a

(The naming-regime column was refreshed 2026-08-01 against the landed R549 state; every operation seam is now regime 1.) The mapping debt this crosswalk once owed is paid: R563 (Done) delivered the member vocabulary as code, the OperationMember per-coordinate member multiset, with the MEMBER_ARMS / MEMBER_KNOWN_GAPS obligation pinning arm coverage; this table stays the seam vocabulary those members map onto.

Two things the crosswalk makes visible. First, select lands on two seams (the projected column list in $project, and the FROM/launch in the Query unit), the back-half echo of target being read by two views in the front half (wrapper by the DataFetcher, shape by the select operation). Second, the additive dissolution is now end-to-end: a coordinate’s operation set is a union of rows, each row is one seam, and "more facts trigger more operations" is "more seams composed into the one Query unit," never a new leaf variant. Composite’s N columns are N select contributions into the same Projection seam; arity is gone from both halves at once.

The bridge also closes thread I over both halves: the front half commits the operation set (which seams must exist), the back half commits the names (regime 1), and referential integrity is that every operation resolves to a committed seam and every seam traces back to an operation or a view. Thread I’s falsifiable test asserts that round-trip.

Query anchors and the two flows

A query anchor is a coordinate whose DataFetcher launches a SELECT: a root field, or a split/new-query field. A query scope’s content is "the QueryParts addressed to it." Every QueryPart carries an address: the anchor whose SELECT it lands in. With that, the two cross-field flows from R316’s wrapper algebra become statements about QueryParts and anchors:

  • Cardinality flows down (transitive): a coordinate’s source.wrapper is the fold of its ancestors' target.wrapper (R316’s wrapper algebra, unchanged). This governs the DataFetcher’s arrival.

  • Key projection flows up (per-anchor): a new-query coordinate’s correlation key is a QueryPart addressed to its enclosing anchor’s SELECT. This is the parent-key injection, no longer a bespoke emit-time relation but a QueryPart with an address. This edge carries a named integrity invariant: when a child’s key tuple is lifted off the parent’s held object, the parent anchor’s projection must contain the key columns. It is a referential-integrity check between the child’s source fact and the parent anchor’s projection ; thread I’s discipline applied to facts rather than method names; the level-1 closure oracle (method-name resolution) does not cover it, so R432 owns adding the containment check. R425 (parent projection omits a @splitQuery/@service child’s key columns) is the shipped bug that shows what its absence costs.

The address unifies composite and split: composite’s column QueryParts are addressed to the coordinate’s own anchor (same scope), split’s key projection is addressed to an ancestor anchor. address in {self, enclosing anchor} covers both.

What dissolves

  • Composite columns: one coordinate, N column QueryParts. CompositeColumnField / CompositeColumnReferenceField and the arity-as-leaf-property retire. (Shipped by R508, 2026-07-21, on all three axes ; output, input, argument ; as the checked-invariant merged carriers ColumnBackedField / ColumnBackedReferenceField / ColumnBackedArg / ColumnBackedReferenceArg, each carrying columns 1..N with arity read off isComposite(). R508 pinned the one-arity-N-carrier form over the N-independent-QueryParts form this item left open; the full per-column QueryPart-row normalization stays with this item.)

  • SplitTableField vs RecordTableField (the lineage trigger): both project the same keyed-re-query QueryPart. SplitTableField additionally projects a key-projection QueryPart addressed to the parent anchor (its enclosing scope is a graphitron-generated SELECT it can impose on); RecordTableField does not (its enclosing scope is a produced record, the key already rides it). They stop being distinct leaves and become the same emit units composed differently. NB: this confirms, rather than overturns, R316’s SourceShape.Table for SplitTableField: its held source object is a jOOQ record materialized by the parent’s query (there is no liveness axis; see the re-query resolution in Open questions); the kinship with RecordTableField is at the keyed-re-query QueryPart, not the source shape. (Shipped: R432 merged the pair to BatchedTableField; the composed-emit-units reading is the live shape.)

  • The leaf cross-product: every "multiplicity-as-a-leaf-variant" modifier becomes QueryPart multiplicity (composite), addressing (split / re-fetch), or shape, not a leaf type. Bulk was never a leaf variant in the first place, it was already the target List wrapper, which is the tell that this is the right cut.

Legacy permit deletion inventory (migrated from the dimensional-model umbrella)

The retired R222 umbrella tracked the legacy-permit retirements as its Stage 5/6 sync points; the inventory now lives here, since the strangler frame (not a stage plan) is what discharges it: a permit retires when its last consumer migrates onto the store, not at a synchronized deletion pass. Status at the 2026-08-06 migration:

  • GraphitronType.InputType four-arm permit: live (GraphitronType.java:365); its sibling root TableInputType retired (R519).

  • ArgumentRef.InputTypeArg.TableInputArg / PlainInputArg: live.

  • The InputField sealed family (ColumnBackedField, ColumnBackedReferenceField, NestingField, UnboundField): live; R589 retires `UnboundField’s demotion-target reading, the carriers dissolve with the input-side migration.

  • HasInputRecordShape capability marker: live.

  • RootField intermediate sub-seal: live.

  • Cross-product field permits: partially discharged. R563’s dissolution slices took the operation axis’s leaf multiplication (51 to 40 at its close); the remainder dissolves arm by arm as classification migrates to claim derivations.

  • TypeBuilder.findReturnTablesForInput: retired (deleted ahead of any sync point).

  • UnclassifiedType / UnclassifiedField and their validator walks: retire per R589 (tombstoning replaced by violation facts; UnclassifiedField survives only as the slot value at a coordinate with no resolvable verdict, until planning joins facts directly).

  • MethodBackedField: its old retirement trigger ("once every per-directive sibling has landed") can never fire, because the ConditionCall / ExternalFieldCall siblings will never be built; capture supplies their content as decoded relations. It retires when its remaining readers migrate.

  • The GraphitronField sealed parent: the end state is deletion of the hierarchy, not the umbrella’s planned rename of OutputField to GraphitronField; under commands-as-parse-targets there is no unified field type at the end of the road.

Discovery: walking the emitters to the method-call-graph

This section is the derivation of the lead model above, not a refinement bolted onto a different one. The chain began from the narrower "one DataFetcher + one or more QueryParts" instinct, which is right about the resolve side but names the SQL unit one level too fine. A 2026-06-18 session walking the actual emitters sharpened it to the model this spec now leads with: the codegen command is the emitted Java method, and the full emit target is a referentially-closed graph of those methods. The threads below record the chain; each is a claim grounded in a current emitter, with the code coordinate that pins it. The line numbers were measured 2026-06-18/19 and drift with trunk; the class and member names are the stable citations (all re-verified live 2026-07-13). Since then the R549/R552/R563 programmes retired several of the walked emitters and carriers, so the threads and the baseline tables below are lineage, read with this mapping rather than re-anchored line by line: TypeClassGenerator and collectRequiredProjection became render/ProjectionUnitRenderer over plan/ProjectionCommands, and the emitted projection method was renamed $fields(sel, table, env) to $project(grouped, table, env) in the same re-platforming; the per-arm Inline* projection emitters (InlineColumnReferenceFieldEmitter, InlineTableFieldEmitter, InlineLookupTableFieldEmitter) dissolved into ProjectionUnitRenderer’s command-driven arms; `TypeConditionsGenerator became render/ConditionGlueRenderer, and FkTargetConditionEmitter became the model record FkTargetConditionFilter rendered via ConditionCommands; LookupValuesJoinEmitter became the render values-join family (LookupRows); the main-source methodgraph package was deleted (the recompile graph is a typed projection over the plan; the test-side oracles survive); ParentProjectionContainmentCheck was deleted with it; SplitTableField / RecordTableField merged to BatchedTableField (R432) and the ColumnField family to ColumnBackedField (R508); the Operation seal became the OperationMember member multiset (R563). The claims the threads ground stand; the pinned coordinates are of their date.

A. SplitTableField and RecordTableField are component-identical (measured). Both records carry the same eleven components (parentTypeName, name, location, returnType, joinPath, filters, orderBy, pagination, sourceKey, loaderRegistration, parentCorrelation) and both implements TableTargetField, BatchKeyField (ChildField.java:446 and :798). The only divergence is two derived methods: emitsSingleRecordPerKey() (Record adds the || dispatch == LOAD_MANY disjunct) and sourceShape() (Split to Table, Record to Record, the switch at ChildField.java:66 vs :79). Nothing in the data distinguishes them; the distinction is which methods consume the leaf and what extra it owes.

B. Functional core / imperative shell; "commands" are the addressed output, not a third concept. R333 already gives every QueryPart an address (the anchor it lands in). That address is the imperative-shell instruction. Naming the lowered units "commands" adds nothing new to the model; it fixes the boundary: the core decides the entire emit, the shell renders and never assembles. The law is commands must be complete: the shell makes no decision the core could have made.

C. The two targets sit at different granularities; the field is right for only one. The DataFetcher is field-granular: one coordinate, one resolver, 1:1, total. FetcherEmitter binds exactly that. The field model’s 1:1 is correct here and stays. The SQL side is not field-granular, and (thread D) is not a query either.

D. There is no complete query at codegen. TypeClassGenerator emits one $fields(sel, table, env) method per table-bound type that assembles the SELECT list from a DataFetchingFieldSelectionSet at runtime. The projected columns are a per-request value gated by the client’s selection set. So the SQL-side command is not a static SELECT, and it is not an org.jooq.QueryPart: a QueryPart is the per-request runtime value those methods produce. This corrects "The two emit targets" above: the SQL-side codegen target is the method that emits QueryParts, not the QueryPart.

E. The command granularity is the emitted method. Reading the output bottom-up, the natural command unit is the Java method we emit, because a $fields arm is not independently renderable (it needs the method scaffold, the switch, the recursion). The minimal renderable unit is the method. The method-command kinds and their granularities:

Emitted method Granularity Owner

<Type>Fetchers.<field>(env)

field (1:1)

the coordinate

<Type>.$fields(sel, table, env)

table-bound type (a fold)

the type

lookup<X> / load<X> rows-method

anchor (the SELECT launcher)

the query-launching coordinate

<field>Condition(...)

field / method

the condition coordinate

createBean / createRecord / <field>OrderBy / <field>InputRows / scatterByIdx

dedup-by-class or per-field helper

the boundary it serves

$fields is type-granular and a fold: it absorbs its own scalar and inline fields, recurses through every NestingField into the nested type’s fields in the same method (TypeClassGenerator NestingField arm at :301-303; nested types get no own class, :146), and opts in the SourceKey columns that Split children need projected into this parent SELECT (collectRequiredProjection). Granularity is heterogeneous across command kinds, and that is the point: each command sits at the granularity of the method it renders. The leaf zoo is what you get from forcing a single field-granular model to source all of these kinds at once.

F. Completeness is a graph property, because methods call each other by name. $fields is not compile-complete in isolation: an inline table-field arm inside X.$fields emits Y.$fields(...) in its multiset projection (InlineTableFieldEmitter:123); the split rows-methods, the polymorphic path, and the lookup path all call <Type>.$fields(...); and self-referential types make the graph cyclic (depth-2 self-reference, per `InlineTableFieldEmitter’s javadoc). So completeness splits in two:

  • Per node: each method command renders a complete body, no intra-method assembly left to the shell.

  • Per set: the command set is closed under reference. Every method-name a body emits (Y.$fields, load<X>, scatterByIdx, a decode helper) resolves to another command in the set, and the core assigned that name on both ends. The shell renders nodes; javac stitches the edges because the names are already fixed.

"Making the code hang together" is exactly the edge-and-name computation, today scattered across the emitters as naming convention (rowsMethodName(), the hardcoded <NestedType>.$fields, scatterByIdx emitted once per class). The cut lifts the whole call graph (nodes, edges, and the naming scheme) into the core; the shell stops knowing any naming convention.

G. Two seams, do not conflate. The static call graph must be closed: that is compile-time completeness, the superset of every edge that could fire. The selection set prunes which edges actually fire per request: that is the runtime subgraph, client data, legitimately dynamic. The core owns the first entirely; the second stays where it is.

H. Normalization, restated for the graph. The emit target is two relations: a node relation (method commands keyed by method name) and an edge relation (calls, as name references). Closure under reference is referential integrity on the edge relation. Two keys bracket the function the core is: the input key is the schema coordinate (parentType, fieldName) (the model key, unchanged from above); the output key is the method name in the emitted graph. The core is the map from input key to a referentially-closed (nodes, edges) relation. The leaf zoo, the per-field QueryParts, and the emitter-computed edges are all denormalized or smeared views of that one relation.

I. Falsifiable invariant (the test this earns). Bidirectional, in the spirit of GeneratorCoverageTest: every method graphitron emits is the render output of exactly one command, and every method-name reference in every emitted body resolves to a command the core committed to, with no emitter minting a callee name. If an emitter ever computes a callee name the core did not hand it, the cut has leaked. This is the test that proves "the shell assembles nothing" rather than asserting it.

J. Naming authority is a measured spectrum, and both ends already exist in-tree. A 2026-06-19 trace of every emitted call edge sorts them by where the callee name is derived, which is what thread F’s closure turns on:

  • Regime 1, model-carried (one derivation locus; both ends read it). The fetcher to rows-method edge reads BatchKeyField.rowsMethodName() (model/BatchKeyField.java:42, whose javadoc states the contract outright: "the fetcher and the rows method agree on this name"); the $fields to join/condition edges read MethodRef.methodName() off a {className, methodName} model value (JoinPathEmitter); the type-condition reads GeneratedConditionFilter.methodName(). This is exactly thread F’s "core owns the name on both ends," already shipped for these edges. MethodRef is the decoupling primitive: a call site reads the name blind, knowing neither the producer nor the derivation.

  • Regime 2, formula-reconstructed (the string retyped at each end, no shared locus). $fields is a literal at the definer (TypeClassGenerator.java:216) and a $$fields template literal independently retyped at roughly eight call sites (SelectMethodBody:112, InlineTableFieldEmitter:123, TypeFetcherGenerator:753,765, SplitRowsMethodEmitter in five places); scatterByIdx / scatterSingleByIdx (literal at definer plus three calls); <Type>Fetchers, <field>OrderBy, <field>InputRows, create<Bean> / create<Record> / decode<Record> (prefix/suffix formula at both ends).

  • The half-migrated seam (closed 2026-07-29, R552). <field>Condition was read from the model at the entity end but recomputed as fieldName + "Condition" at the shim end: one name, two loci. The condition command closed it by dissolving the second locus rather than repointing it; GeneratedUnits mints every condition class and method name and both retired generators' formulas are gone.

The R2 set is the worklist for thread F’s closure; the cut is "make every edge look like MethodRef / rowsMethodName, none like $fields." This makes thread I’s invariant grep-able: every $$fields, scatterByIdx, + "Condition", + "OrderBy", + "Fetchers" outside a single mint point is a current violation, so the test starts red and the migration drives it green edge by edge.

K. Seams, not the current emitters, define the target. The emitter inventory below, and any pair table read off it, describe the current seam topology, which inlines heavily and is therefore not the destination. A seam is a named method call: the one place an edge (and a regime-1 name) can exist. Inlining is the absence of a seam, producer and consumer welded into one body. So "add a seam," "promote an inlined fragment to a core-minted node + edge," and "make a new pair possible" are one statement; the seam topology is the node/edge relation of thread H, and designing it is the content of the lowering.

The current resolve side is asymmetric. The child path factors its query into a named unit (child fetcher to DataLoader to rows<X>, the rows-method being the select / from / where / orderBy / $fields assembly as a named method). The root path inlines that same assembly into the fetcher body (TypeFetcherGenerator’s root builders; a 2026-07-26 code walk corrected the earlier `SelectMethodBody attribution, that class being the already-named entity-dispatch unit), with no rows<X>-equivalent to call. Root and child build the same query two ways; only child names it. The decided target (2026-06-19) closes that seam: both fetcher kinds become thin entry points delegating to one shared query unit, differing only in invocation strategy (root calls it directly; child calls it batched through a loader plus scatter). This generalizes the SplitTableField = RecordTableField shared-rows-method (the one existing instance of reuse-via-seam) into the organizing principle. Service-backed is the parallel arm: the fetcher delegates to a named service-call unit instead of inlining service.method(...). The root path gains a level of indirection not required by runtime (no batching to justify it); paying it to buy uniformity, testability, and reuse is a deliberate, accepted trade.

Target topology, uniform across root / child / service:

  • DataFetcher (thin entry; picks a strategy) delegates across a seam to either

  • the Query unit (the SELECT launcher; today’s rows-method, generalized), invoked directly (root) or batched through a DataLoader plus scatter (child); or

  • a service-call unit (the service-backed arm).

  • The Query unit composes across further seams into the query-part units: Projection ($project), Join, Condition, OrderBy, and so on.

Seam-placement rule. A seam belongs wherever a unit is (a) chosen by a runtime strategy/dispatch, (b) reused across more than one caller, or (c) something we want to assert independently in the corpus or tests. Inline only a linear, single-use, non-varying construction. On the jOOQ side, where jOOQ’s own in-language composition means a QueryPart can be an inline expression or a named method, we take the looser reading of (c): seam wherever the corpus might want to assert, accepting the parameter-threading cost (env, dsl, table, selection set across each seam), rather than reserving assertion for the query-unit level. The brake against one-method-per-QueryPart is that (a)/(b)/(c) must each be a real, named reason; "it is an expression" is not one. Testability is the through-line: an inlined fragment is assertable only through the whole query that contains it, a named query-part unit is independently assertable and is a clean regime-1 edge by construction, so "more seams," "more testable," and "more decoupled pairs" are the same axis.

Current seam topology (migration baseline)

The pairs below are the whole-method nodes the current generator already cuts; they are the baseline the target seam topology is migrated from, not the target itself. The R1/R2 column is thread J’s naming regime; the R2 rows plus the missing seams of thread K are the promotion worklist.

Pair (node) Node it mints Granularity Whole-method emitter today Outbound edges to pairs Naming regime

Fetcher

<Type>Fetchers.<field>(env)

field, 1:1, total

FetcherEmitter, DataLoaderFetcherEmitter (body via TypeFetcherGenerator)

Projection (root), Rows-method (child), Bean/Record, OrderBy

class R2, method R1

Projection

<Type>.$project(grouped, table, env)

type-bound fold

ProjectionUnitRenderer over plan/ProjectionCommands (command-driven; R549)

Projection (recursive; cyclic), Condition/Join

R1 (GeneratedUnits)

Rows-method

rows<X> / load<X>

anchor (SELECT launcher)

SplitRowsMethodEmitter, MultiTablePolymorphicEmitter, RootLauncherRenderer (root/lookup)

Projection, Scatter, InputRows

R1

Scatter

scatter*ByIdx

dedup-by-class

SplitRowsMethodEmitter, RootLauncherRenderer (lookup scatter, R617)

leaf

R2

Condition

<field>Condition

one glue method per (coordinate, table) row

ConditionGlueRenderer (command-driven; landed 2026-07-29, R552)

Join

R1 (GeneratedUnits)

Join

join-path helper (MethodRef target)

per join path

JoinPathEmitter

leaf

R1

InputRows

<field>InputRows

per lookup field

LookupRows (render values-join family)

Join

R2

Bean/Record

create<Bean> / create<Record> / decode<Record>

dedup-by-class

InputBeanInstantiationEmitter, JooqRecordInstantiationEmitter

leaf

R2

OrderBy

<field>OrderBy

per orderable field

OrderByResultClassGenerator

leaf

R2

The cyclic core is three pairs (Fetcher to Projection to Rows-method to Projection), thread F’s cycle. Pair = whole emitted method. Renderers that produce only an arm are sub-renderers that fold into a pair, not pairs: ProjectionUnitRenderer’s per-contribution arms inside `$project; ServiceMethodCall / ChannelCatchArm / ChannelEarlyReturn in the fetcher body; the ArgCall fragments. That partition resolves the node-relation granularity fork (one pair per emitted-method-kind), and the seam-placement rule of thread K governs which methods exist in the target.

Emitter inventory (grounding for E and F)

The emitting classes (eighteen *Emitter`s plus the command-driven `render/ layer, re-measured 2026-08-10 after the R549/R552 re-platforming) divide by what they emit:

  • Resolve side (DataFetcher), field-granular or finer: FetcherEmitter (one field to one DataFetcher), DataLoaderFetcherEmitter (one DataLoader-backed DataFetcher method), ServiceMethodCallEmitter / ChannelCatchArmEmitter / ChannelEarlyReturnEmitter / TenantDslEmitter (fragments inside a fetcher body), InputBeanInstantiationEmitter / JooqRecordInstantiationEmitter (boundary helpers).

  • SQL projection arms (pieces of $project): ProjectionUnitRenderer’s per-contribution arms, driven by `plan/ProjectionCommands rows (the former per-arm Inline*Emitter classes, dissolved).

  • SQL sub-SELECT fragments (shared): JoinPathEmitter, ArgCallEmitter, RoutineCallEmitter (R435), the LookupRows values-join family, and FkTargetConditionFilter rendered via ConditionCommands / ConditionGlueRenderer (R552).

  • SQL anchors (launch a SELECT, call $project): SplitRowsMethodEmitter, MultiTablePolymorphicEmitter, RootLauncherRenderer (plus the root and lookup paths in TypeFetcherGenerator and SelectMethodBody).

  • Schema side (SDL and wiring): AppliedDirectiveEmitter, DirectiveDefinitionEmitter, FetcherRegistrationsEmitter, GraphQLValueEmitter, SchemaSdlEmitter.

The projection arms are pieces of one node ($project); the anchor emitters are nodes with outbound $project edges. That split is the evidence for E and F.

First slice (the beachhead)

Shipped (R432, 2026-07-15). The beachhead landed: SplitTableField + RecordTableField merged into BatchedTableField and the lookup twins into BatchedLookupTableField, each gated on a stored SourceShape with a total KeyLift (the split arm carries FkColumns); the parent-projection containment check named under Query anchors and the two flows shipped first (ParentProjectionContainmentCheck, keyed on BatchKeyField + sourceShape()). Generated output stayed byte-identical across the sakila corpus in both merge slices. Leaf mentions of the four retired names below this point are historical design narrative; the live names are the two Batched* leaves. The fetcher fork initially survived inside one seam gated on sourceShape; R314 then unified it into the one source-shape-gated batched fetcher (see the R314 shipped-note under Relationships).

SplitTableField / RecordTableField is the cheapest honest demonstration of the cut. Both child sides lower to the same load<X> rows-method and the same fetcher; Split’s only extra is the key projection, which relocates to the parent type’s projection unit (at the walk’s date collectRequiredProjection; today ProjectionCommands’ gated correlation-key rows). Collapsing the two with zero residue, gated on `sourceShape, retires one cross-product axis with no generator rewrite and produces the lowering’s first executable proof. It is the smallest instance of cross-anchor key relocation, so it exercises the address-as-name-resolution machinery on exactly one contribution.

Relationships

  • R316 (source-operation-target-pivot): the triple this normalizes. R316’s leaf-reconstruction table already separated field identity (the schema coordinate) from the dimensional content; this makes that seam structural and reframes leafReconstructsFromCoordinate as "lower the coordinate to its DataFetcher + QueryParts" (the leaf zoo being the denormalized form). R316 stays the stepping stone; this does not reopen its slices.

  • R314 (dissolve-reentry-leaves-dimensional-emit): this is the structural enabler, and the sequence is now decided (2026-07-04). R314 stays the reentry slice of the emit re-platforming, re-specced onto this model’s vocabulary; it does not widen into an umbrella. The run-up was R431 (decompose-sourcekey, eager, first) then R432 (collapse-split-and-record-table-leaves, the beachhead), then R314 emitting the reentry family off the model and retiring dispatchPerformsReFetch; all three are Done.

      > *Shipped (R314, 2026-07-16).* The reentry slice landed across five implementation commits
      > (`+d1f13a2+` site-level fact + command registry + bidirectional oracle; `+7137d1e+` the one
      > source-shape-gated batched fetcher; `+4abde9e+` the DTO-parent re-entry leaf dissolved onto the
      > record-sourced `+BatchedTableField+`; `+4e04345+` the row-15 verdict pinned + root service leaves documented;
      > `+11122a4+` the named DML reentry rows companions; `+1158c14+` `+dispatchPerformsReFetch+` retired
      > for the reentry implementedness guard). Thread I's level-2 command/name registry landed in
      > main source (the `+methodgraph+` package), populated for the whole reentry
      > family and joined bidirectionally by `+ReentryCommandClosureTest+`; the R549 programme later
      > retired that registry in favour of the command relations themselves (2026-07-30, see *Scope*). The DML correlation
      > rendering residue (keys-IN vs the VALUES-join primitive) shipped 2026-07-21 (R489,
      > `+0ef2353+` + rework `+1f8340e+`, see `+changelog.md+`): the companion renders the VALUES-join
      > primitive at bulk cardinality and plain key equality at single, with the correlation
      > carried on the `+DmlReturnExpression+` reentry arms.
      Acceptance across the run-up is *execution-tier equivalence* (same rows, same order, error paths
      intact), not byte-for-byte generated-output equality: the goal is gradual improvement toward this
      model, and slices may normalize generated-code shape as they go.
    - *R549* (facts-and-commands, Done 2026-08-01; see `+roadmap/changelog.md+`): the emit-re-platforming
      programme, and the successor to the wholesale reading of R314 above. It consumed this model without
      re-litigating it, labelled the sealed hierarchies by provenance and grain, and executed the back half
      as coordinate-keyed command relations (launcher, condition, projection, fetcher-edge) the shell folds
      over, with the projection command as its keystone. The seam worklist above was this document's living
      table through that window: the rows were updated as each family's verdict landed, and the table plus
      the member-to-seam crosswalk were refreshed against the shipped state 2026-08-01.
    - *R563* (operation-relation, Done): the front-half programme of its window. It consumed this model's
      normalized schema, trigger rule, member-to-seam crosswalk and corpus resolution verbatim, and
      delivered `+coordinate -> operation+` as the `+OperationMember+` member multiset, dissolving the
      classifier's operation-encoding leaves; the crosswalk debt named above is paid. The front-half
      succession is now the R595/R589 pair below, and R589 explicitly holds this item in Ready.
    - *R595* (`+graphitron-model-captures-facts+`, the substrate): the `+graphitron-model+` module whose fact
      schema DDL is this document's normalised data model reified as SQL, filled by two infallible capture
      loads running beside the pipeline in a shadow window with agreement tests. The division of labour is
      stated in *The model*: this document keeps the why, the DDL owns the what, and where they disagree the
      DDL wins.
    - *R589* (`+validation-adds-facts+`): adds the claim base relations this model's catalog now carries,
      relocates the single-classification worldview to planning (the reduced claim view), names the commands
      as the parse targets, and sets the migration mechanism (the strangler frame in *What the model
      enables*). Its slice 1 is the amendment pass that produced the current text of this document.
    - *R222* (dimensional-model-pivot): the umbrella this model grew out of, now retired to lineage. Its
      slots-over-cross-product thesis won one layer below where it was looking (axes are per-directive
      relations and the claim view's arm list), its stage machinery was replaced by the strangler frame,
      and its legacy-permit deletion inventory migrated into *What dissolves* below. The disposition record
      is `+roadmap/audits/2026-08-06-fact-base-impact-sweep.md+`; *this document governs the model*.

Directive coverage

Every active directive declares a behavior the model must lower, so the model is complete exactly when every directive’s effect has an owning fact. This is the audit that drives the remaining work: walk the directives, map each to its owning fact, and the ones with no home are the gaps. (The retired directives, @record / @notGenerated / @multitableReference, are parser-only stubs the classifier rejects; they own nothing by design. @enum is still honored today: its retirement is decided by this model (Enum facts) but filed as R360, Backlog, so until that ships its effect is owned by the enum facts as the authored backing they supersede.)

Owned by an existing fact:

Directive(s) Owning fact

@table

source.table / target.table / resolvedTable

@routine

tableExpr RoutineCall as a @reference join-target node (the join path)

@sourceRow

the source-side key provenance Lift(lifterRef) (the join path)

@discriminate / @discriminator

the discrimination fact (type recovery, RecordClass | DiscriminatorColumn)

@error

the discrimination fact (exception-domain partition) + operation-side errorGuard (channel, interned handler set)

@field

the locator (output) / column binding (input) / EnumValue.runtimeValue

@externalField

the locator’s typed-jOOQ-field arm

@reference

the reference fact (path, referencedTable, joinPath)

@service

operation: serviceCall

@condition

operation: condition (+ override suppression)

@lookupKey

operation: Lookup

@mutation

operation: DML

@asConnection

operation: paginate

@asFacet

the contained connection unit (R13): authored at the filter input’s member coordinate, resolved use-site onto ConnectionType.facets() as a denormalized view carrier; the normalized operation-fact home lands with R314’s aggregate member, a distinct anchor rather than a sub-fact of paginate

@orderBy / @defaultOrder / @order / @index

operation: orderBy (sort-key source a sub-fact)

@splitQuery

the operation address (split / new-query anchor)

@node / @nodeId

node facts (NodeType / NodeKeyColumn / projections)

@field on ENUM_VALUE

enum facts (EnumValue.runtimeValue, derived EnumBacking)

@scalarType

the column’s javaType + boundary coercion (registration is a synthesis concern)

Planned, not yet a directive: @capability / @exemplifies will own a capability_tag(coordinate, slug) relation plus the slug namespace (a third referenced namespace; see Provenance, description, and capability). They are read by the knowledge surface, not by code generation, and the audit will list them in the table above once they ship.

The gaps, in resolution order:

  1. Transform-definition (the read-side "transform"). Resolved (this session). There is no transform axis: the read is a locator plus references to Column and node facts; the scalar / enum conversions are column facts lifted at the boundary, and @nodeId is node facts. See Reading the source object / Node facts / Enum facts.

  2. Table expression and the join path (@routine). Resolved (this session). resolvedTable stays the table class; tableExpr (arms Catalog | RoutineCall) materializes a table node. @routine is a RoutineCall node that can sit anywhere, the projected terminus (a function-backed query), an intermediate, or the root FROM-entry, in the @reference path: a join step is (tableExpr target, on?) with on a ColumnPairs | Predicate, and joins adjacent to the FK-less routine are keyed by a name-matched key: or a condition:. The @reference path is the linearized join graph; verified rules (source-gives-first, terminus-equals-target, table: = derived-key:, root-iff-routine) hold. See The table expression and The join path. The SDL surface settled (2026-07-05) as R435: order-significant, repeatable @routine / @reference co-occurrence composes the chain, so no @oneOf path-element surface is needed at all.

  3. Discrimination (@discriminate / @discriminator). Resolved (in model). Not a conditional cast, and no break of the monomorphic axiom: an interface/union recovers its concrete type first (the read-side dual of the accessor), then reads monomorphically per concrete type. The discriminator’s signal is RecordClass (type survives, distinct tables) or DiscriminatorColumn(value → type) (same-table participants, or a UNION ALL-erased read whose synthesized __typename only undoes the erasure), gated by the discriminability invariant (same-table-without-discriminator is a build error). Distinct from the deferred producer-polymorphism. See Discrimination.

  4. Error mapping (@error). Resolved (in model). Not a new axis: it is discrimination in a second signal domain. The polymorphic value is a caught Throwable and the recovered types are the payload’s @error types; an @error type is a source object whose fields read off the exception (accessor), and which error type is a partition over an exception signal (ExceptionClass | SqlState | VendorCode | Validation), the dual of DiscriminatorColumn. It stays a partition (no authored ordinal): matches is a per-cell refinement filter, subtype overlap resolves by most-specific-class-wins, cause-depth by a fixed outermost-first walk, and the partition is total via a redact complement. The one genuinely new sub-fact is operation-side, errorGuard(channel, interned handlerSet), on throwing operations. See Discrimination.

  5. DTO-parent join-key lifter (@sourceRow). Resolved (this session). The source-side dual of the routine target: the parent-side join key has a provenance gated by the source-object shape, RecordColumns (jOOQ parent, inferred) or Lift(lifterRef) (class-backed DTO, authored via @sourceRow). It changes only where the source-side values come from, the columns are real catalog columns and @reference navigates the FK chain unchanged. Absorbed into the join-path model but not retired, a DTO’s key extraction is opaque Java, not inferable. See The join path.

  6. @experimental_constructType. Deferred (experimental). A per-field column-selection construction map for non-resolvable federation entities; an explicit defer rather than a fact to build now.

Open questions (to settle before / during Ready)

  • @routine and the join path. Resolved (this session): see The join path. A routine is a RoutineCall join-target node in the @reference path (or the root FROM-entry), joined by a name-matched key: or a condition:. The FROM-graph generalization collapses into the linearized join path, no separate top-level structure. A routine node may be the projected terminus (a function-backed query) or a join target / root entry; the only constraint is FK-less joins adjacent to it. Residue discharged (2026-07-05) by R435 (routine-table-node-composition): (a) the SDL surface is not a @oneOf path element; it is order-significant, repeatable @routine / @reference co-occurrence, the field’s directive list read left to right being the linearized join graph (ReferenceElement untouched); (b) the root-iff-routine guard becomes R435’s explicit first-application-supplies-the-head validator. (When that residue was filed both directives were unused and the pre-R435 @routine code allowed only the projected-result shape, with no composition.) R435 has since shipped (Done; see changelog.md), landing the composition on this model’s own vocabulary (TableExpr.RoutineCall, JoinStep.Hop, On.Lateral, the name-matched key), with follow-ups R449 (classification edges), R450 (split-path hop-0 filter binding), and R451 (routine writes) also Done.

  • Node-relation granularity (the open fork from the session). Resolved (thread K): the node is one pair per whole emitted method; arm-renderers fold into a pair. Which methods exist in the target is governed by the seam-placement rule, not by a fixed count.

  • Edge inventory and naming authority (was the next read-only step). Resolved (threads J/K): the 2026-06-19 trace sorted every edge into regime 1 (model-carried, e.g. rowsMethodName / MethodRef, the target pattern) and regime 2 (formula-reconstructed, e.g. $fields at roughly eight sites). The R2 set is the naming-authority worklist; "add a seam" promotes an inlined fragment to a regime-1 edge.

  • Anchor addressing depth: does a QueryPart’s address name the enclosing anchor coordinate directly, and is the up-projection one-hop (immediate parent) or nearest-query-owning-ancestor with inline ancestors transparent (a split grandchild under an inline child threading its key to the grandparent’s SELECT)? Partly resolved by threads F/H: addressing is core-side name resolution, and the parent-key projection is already implemented as "opt these columns into the parent type’s $fields`" (collectRequiredProjection` in TypeClassGenerator at resolution time; since re-platformed onto plan/ProjectionCommands rendered by render/ProjectionUnitRenderer). The open residue is the grandchild-through-inline-ancestor threading, not the primitive.

  • Re-query unification. Resolved (2026-07-04): full merge, laundered key. The keyed re-query is one primitive, f(keys, correlation): VALUES(idx, key...) joined to the target over a correlation that is the FK column pairs for split and PK self-identity for re-fetch (the degenerate case named in Two levels of natural key). The source endpoint’s only contribution is how the key tuple is lifted, and the lift owns no machinery of its own: it is N reads through the same field-level locator facts the ordinary read side uses, gated on the held object’s shape. Exactly two lift arms, matching the source-object shape: a jOOQ record (project columns) or a Java object (read members: record component / getter / field, one locator family). @sourceRow’s lifter is provenance on the member-read arm (authored where the catalog cannot infer the mapping), not a third mechanism, consistent with provenance everywhere else in this model; a lifter yielding many key rows per parent is the wrapper algebra’s business (source-field arity), not the reader’s. There is no liveness axis: every fetcher reads a held object its parent’s fetcher deposited, and split’s parent (a jOOQ record materialized by the parent query) is exactly as held as a service’s DTO; "same keys, same rows" is the whole contract, and arguments flow into children identically regardless of source. Consequences: `SplitTableField / RecordTableField collapse to one leaf gated on the source fact with zero residue (the beachhead, filed as R432), the key contract becomes the named parent-projection invariant in Query anchors and the two flows, and `SourceKey.Reader’s seven arms are confirmed as shape x provenance x envelope conflated into one seal, dissolved by the decomposition (filed as R431).

  • DataFetcher totality vs synthetic nodes. Resolved (2026-07-04): confirmed against the emit. Every coordinate has exactly one DataFetcher (an SDL field has one resolver), so there is no "synthetic DataFetcher"; and there is likewise no synthetic coordinate in the current emit: the parent-key projection rides the projection opt-in into the parent type’s $project (collectRequiredProjection at resolution time, now the ProjectionCommands opt-in rows; a QueryPart owned by the splitting coordinate, addressed to the enclosing anchor), and the __idx__ scatter column is a synthetic column inside one query scope, never a fabricated SDL field. The model asserts this as an invariant: coordinates come only from the SDL.

  • Corpus assertion shape: the @classified verdict generalizes from one triple to the (DataFetcher, QueryPart*) decomposition. Resolved by the normalized schema: the directive asserts the coordinate’s source fact, its target fact, and a set of operation rows, each independently assertable (an operation is a regime-1 seam by construction). This is the same set framing the leaf cross-product dissolves into; the residue is only the rendering of an operation set in the corpus, not whether it is one or many rows.

  • Materialization: discipline vs runtime. Re-resolved (2026-08-06): the store is adopted. The first resolution ("discipline, not a runtime") was reversed by R595/R589 with each of its three arguments answered by evidence; the data-modeling section carries the argument-by-argument record. The relations materialize in the graphitron-model H2 store through jOOQ codegen over the model’s own DDL; integrity splits into declared FKs (capture-structural) and detection queries (author-spelled); thread I’s closure invariant is untouched on the emit side. The salsa-style incremental engine once reserved is the road not taken; the warm-start cache (R595) is the surviving concession.

  • Condition placement and the Single value-gating semantic. Resolved (the resolved-table section): a condition keys on resolvedTable, and its semantic forks on target.wrapper (List = row-set filtering, Single = value-gating). Open residue: the Single value-gating semantic itself, the predicate’s ON-clause placement under a LEFT JOIN and the parent-cardinality-preserved invariant. Owed for to-one table references regardless, so allowing column-reference conditions adds no new debt here.

  • The List(Column) corner: the to-many child column reference is named but unmodeled (today’s ColumnBackedReferenceField is only Single(Column)). Settle whether it lands as a wrapper variant of the reference fact reusing the to-many table-field machinery (a Child source, an anchor, a rows-method, projecting one column instead of $fields), or as its own leaf, before it is implemented.

  • Override suppression granularity. Started maximal: @condition(override: true) blankets the consumed path’s entire generated subtree, the generated conditions and the join`s minted to serve them. Chosen for simplicity, on the bet that an overriding author owns that branch’s SQL. Open residue: narrow to conditions-only (leaving an input-side reference’s `join standing for a hand-written predicate to use) only if a use case requires it. The per-field, subtree-scoped rule is easy to relax that far.

  • Read-side facts (the source object and the accessor). Resolved (the Reading the source object and Node facts sections):* the read decomposes into a type-level source object fact (a cast-target record shape, never a table, with table-boundness a separate build fact) and a field-level *accessor fact (a locator gated by the source object). There is no transform axis: what looked like one is Column facts (scalar / enum coercion, entailed by the column type and lifted at the graphql-java boundary) and node facts (the @nodeId codec, entailed by a node’s key definition; the key projection is the read-side locator, and a non-identity-carrying path rides the reference fact’s join / EXISTS). Open residue / deferred: producer-polymorphism (a type with disagreeing producer shapes) is asserted away by the uniform-producer axiom, and the partial node-key projection (some key columns local, some joined) is collapsed to all-or-none; both named but to revisit only if a use case forces them.

Scope

In scope: the model (the lowering to a referentially-closed method-call-graph, the normalization, the natural keys, the anchor/address primitive, the node and edge relations, the coordinate-and-its-facts normalized schema (source / target / operation as independent functional dependencies, plus the reference / referencedTable / derived resolvedTable / tableExpr facts and the input-coordinate fact family (definition-keyed authored facts, use-site resolution over the derived occurrence path) whose facts roll up into the output operation set, the read-side source object (type-level cast target) and accessor (field-level locator, no transform axis) facts, the node facts (NodeType / NodeKeyColumn plus the per-coordinate key projections, with the codec entailed and identity-carrying paths deciding whether the read stays projection-only or rides a join), the enum facts (authored value set plus a derived EnumBacking backing-type roll-up driving a synthesis-time lift, @enum retirement decided, filed as R360), the DataFetcher and QueryPart-methods as views over them), the target seam topology and its placement rule, and the materialization decision as re-resolved: the relations live in the graphitron-model store, reified from this model’s DDL, with integrity split between declared FKs and detection queries). Out of scope: the emit re-platforming that consumes it (the R549 programme; R314 shipped its reentry slice), any rewrite of R316 slices 1-4 (they are the valid denormalized projection), the substrate itself (R595) and the classification-stage derivations over it (R589), and any incremental-query engine for the LSP (the road not taken; R595’s warm start is the concession). No code in this item beyond what is needed to make the model executable as tests, and that split is now decided (2026-07-04): the Ready code deliverable is thread I’s closure oracle at level 1, a characterization harness over the current emit that walks the generated TypeSpecs, collects declared method names and intra-generated call references, and asserts every such callee resolves to an emitted method (R410’s TypeSpecReferenceWalk is the same walking pattern at file granularity). Level 1 is valid before any re-platforming and survives it as the harness. The bidirectional form (every emitted method is exactly one command’s output; every callee name resolves to a committed command) needed committed names in main source. R314 (Done) first landed them as a dedicated command/name registry (the main-source methodgraph package); the R549 programme then re-sourced the closure onto the command relations themselves and retired that registry (2026-07-30): the relations' minted unit addresses (GeneratedUnits) are the committed names, and the closure joins bidirectionally through the re-sourced tests (LauncherRelationClosureTest and the level-1 MethodClosureOracleTest, both still in the test-side methodgraph package).

Landed (2026-07-14): EmittedMethodClosure (the walk: node relation = declared methods keyed by unit + nested-type path; edge relation = statically-qualified generated-to-generated callee references, resolved through the rendered import list / same-package / FQCN the way javac binds them, with comments and string literals blanked) plus MethodClosureOracleTest (pipeline tier, no.sikt.graphitron.rewrite.methodgraph), generating over a schema that spans the seam-worklist families (root select, child reference, @splitQuery, connection + @orderBy, @lookupKey, @nodeId filter conditions, nesting, table-bound @service and DML reentry, jOOQ-record service input) and asserting closure plus per-family non-vacuity floors on both relations. EmittedMethodClosureTest (unit tier) pins the scanner’s falsifiability: a dangling callee turns the oracle red; javadoc prose, string literals, constructors, enum-constant reads, and non-generated qualifiers do not. Documented level-1 blind spots, both closed by javac in the compilation tier and by the level-2 registry later: unqualified same-class calls (the rows-methods, scatter, order-by, record-instantiation helpers ; pinned as nodes instead) and instance calls through variables.

Reviewed (2026-07-14, In Review pass): the level-1 oracle is approved as shipped at 446ad39 (full reactor green under -Plocal-db; both test classes executed, closure and non-vacuity floors hold; the fresh scanner is the right call since R410’s TypeSpecReferenceWalk is type-granular, not method-granular). The item returns to Ready rather than Done-and-delete because the code deliverable was one phase of a still-live artifact: this document remains the governing model (R222 Relationships: "this document governs the model") consumed by R431 / R432 / R314 / R222, whose specs cite its sections by name and whose depends-on names its slug; the seam worklist is a living table iterated as those slices land; and the body names its own remaining pass (folding the pre-sharpening "QueryPart" wording through the sections from Normalization to What dissolves). Done-and-delete follows when the emit re-platforming no longer consumes the document or the stabilized model migrates out: to docs/architecture/, or into the graphitron-model DDL, the door R595 opened. The what is already migrating relation by relation; the why recorded here is the last content standing, and its eventual home is the architecture docs.

Lineage

Surfaced 2026-06-18 while researching a claim that SplitTableField is a variant of RecordTableField that should dissolve. The emit trace refuted the literal "source is a record" reading (split reads a live catalog row) but confirmed the structural kinship (identical record components, shared batch-load machinery). Pressing on "what is the real difference" produced the double-duty observation (split also injects keys into the parent SELECT), then the parent-projection-as-up-flow relation, then the insight that reifying it as a separate node dissolves the relation, and finally the normalization framing: the node is a QueryPart, the SDL field is the natural key, and the leaf zoo is a denormalized view. The chain is preserved in the R316 design discussion of the same date.