ID |
|
|---|---|
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 |
|---|---|---|---|
|
1:1, total |
parent + the edge into the field |
where the source object arrives and how many; the parent shape |
|
1:1, total |
|
the field’s own output wrapper ( |
|
0..N (a set) |
|
the QueryPart-emitting commands the query unit composes |
|
0..1 |
|
the value lives off the parent’s table; lowers to a |
|
0..1 (iff |
|
the reference’s destination table, named in its own right |
|
0..1, derived |
coalesce |
the catalog table the |
|
per table node |
|
materializes a table node ( |
|
the `reference’s resolved form |
|
the linearized join graph: a start node plus ordered `JoinStep`s |
|
type-level |
the parent type’s record shape ( |
the cast-target record the read casts to; never a table |
|
1:1 (read side) |
|
the field-level locator that reads the value back out |
|
accessor refinement, 0..1 |
|
the nodeId codec and its key projections |
|
accessor refinement, 0..1 |
|
the authored value set and a derived backing type |
|
0..1 |
|
concrete-type recovery, over the row or exception signal domain |
|
operation sub-fact |
|
on a throwing operation: a transport channel and an interned handler partition |
|
0..N (reserved) |
|
tags the coordinate with a stable slug from the capability catalog; knowledge-surface only, not yet shipped |
|
0..1, split by namespace |
SDL positions stored as capture columns; Java/Javadoc positions joined against |
the |
|
0..N, keyed |
one view arm per claiming |
the classification axis’s authored claims; the reduced view is the single-classification worldview planning reads (R589) |
|
0..N, keyed |
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 |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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, oneDataFetcher, total.FetcherEmitterbinds 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.
ConditionGlueRendereremits 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 jOOQCondition, its body shape (eq/in/row(...).eqfor 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.DataFetcheris 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.QueryPartis not an emit target at codegen. The SQL projection is assembled at runtime from the client’sDataFetchingFieldSelectionSetinside$project; theQueryPartis 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/CompositeColumnReferenceFieldcarried an arity-Ncolumnslist, a repeating group (a 1NF violation). Normalizing to atomic rows yieldsNsingle-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 theColumnBackedFieldfamily, one carrier withcolumns1..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 filesparentTypeName / name / locationas "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’sNcolumns, 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 offfield.getType(). -
coordinate -> operation, 0..N. The operation set (the QueryPart-emitting methods, thread E’s SQL-side commands).operationis not one forced verb but a set: a single field canselectandjoinandconditionandpaginateandorderByat 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 ajoinoperation, withjoinPathas its resolved form. Detailed below in Thereferencefact. -
coordinate -> referencedTable, 0..1. The reference’s destination table, named in its own right. Present exactly whenreferenceis. Not the same assource.table(a self-referential FK makes them coincide while ajoinis still minted). Detailed below in Thereferencefact. -
coordinate -> resolvedTable, derived, 0..1. The catalog table the@fieldresolves 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 theColumnor 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 |
|---|---|---|---|
|
|
DataFetcher |
invocation cardinality; |
|
|
DataFetcher |
output cardinality; the resolve-side contract |
|
|
a |
projection kind (a jOOQ |
|
|
DataFetcher only |
read off a Java object; zero operation rows |
|
|
the operation’s own method |
the SQL verbs, now set members |
|
|
DataFetcher delegates to it |
|
|
(none: empty set) |
DataFetcher only |
a regroup with no SQL; the DataFetcher’s existence is the fact |
composite arity |
|
the |
the number of projected columns, not a coordinate dimension |
split / re-fetch / new-query |
operation |
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, soreferencesupplies both. It moves the read-table off the parent onto the destination. This is what reads as "altering the source". -
Table target (
TableFieldand kin): the destination is already pinned by the nested type’s@table, soreferencesupplies 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 |
|
|
Table |
|
|
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
-
referencedTablefirst: 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 shadowssource.tableand equalstarget.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 theNestcase: still SQL-backed, no join, noreferencedTable, and distinct from theRecord/Fieldshapes 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 noreferencedTableand enters via the FROM clause.target.tableis defined only whentarget.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
joinoperation’s destination isresolvedTable(whenreferenceis present); -
the
selectoperation projects fromresolvedTable; -
a field’s
resolvedTableis its children’ssource.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
RoutineCallnode 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
referencepath’s terminus class==thetableExprcall’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, sopath[0]’s table is the second table in the sequence; `sourcesupplies the first. -
The terminus equals
target. The last step’s target isresolvedTable(thereferencedTable == target.tableinvariant, restated); callers assert it. -
table:iskey: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 anAuthorError. -
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. Akey:/table:/condition:first element joins from a source and stays root-invalid. So a root@referencereadsFROM routine(args) → … → target, the routine the entry, the projectedtargetthe 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 bytableExpr(Catalog|RoutineCall). -on(0..1):ColumnPairs(List<(sourceCol, targetCol)>)|Predicate(MethodRef). Absent only for the start node (a root routine entry, or the implicitsource.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, thetable: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:xorcondition:(both is anAuthorError); 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 acondition:, 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 |
|---|---|---|
|
jOOQ-backed (a record / |
inferred (catalog) |
|
class-backed (a DTO) |
authored ( |
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 ofresolvedTablecontribute. Standard, no parent-cardinality hazard (the set is already per-parent, batched or aggregated). True identically forList(Column)andList(Table). This is why allowing child references makes conditions obviously sensible: aList(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 aLEFT JOIN, so a failing predicate nulls the value rather than dropping the parent row. This subtlety is a property of theSinglewrapper, shared bySingle(Table)andSingle(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⇒ ajoinon the output query. This is why input-side@referenceis 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 itsresolvedTable) ⇒ a generatedcondition, with operator from the inputtarget.wrapper(Single⇒eq,List⇒in, 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 throughget(Field<T>), so the concreteFilmRecordis never needed, and the typed-vs-sparseTableRecord/Recorddistinction 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-generatedFILM.TITLEand a graphitron-generated field read identically viarecord.get(thatField), which collapses the presentColumnBackedField-read andComputedField/@externalField-read into one arm and retires theColumnRef-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 (theNestingFieldidentity read). -
localContext /
Outcome.ErrorListarm: where an errors list lives (today’sErrorsField.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:
-
Directis identity: the absence of any conversion fact, a bare read. -
EnumValueOfandJooqConvertareColumnfacts, not transforms. The wire→SDL coercion is graphql-java’s, type-level (anIDarrives asString, an enum literal as its registeredvalue); 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 isDirect(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 theID-only trigger is R261.) -
NodeIdEncode/decodeis 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 |
|---|---|---|
|
|
|
|
|
|
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 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 |
|---|---|---|
|
|
|
|
|
|
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 |
|---|---|---|---|
|
|
authored (SDL) |
|
|
|
|
authored (SDL) |
|
|
|
derived (roll-up of producer |
|
|
catalog, only when |
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 →
runtimeValueparsed to the numeric type -
String →
runtimeValueitself
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
runtimeValueis valid inbackingType, theEnumConstantname match for a Java enum, parseability for a numeric, trivial for String. This generalizes the comparability check; only the Java-enum case needsEnumConstant. -
One enum, one type: every producer the enum resolves to has the same
backingType, which licenses the type-level roll-up and the single registeredvalue. 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-returnedTableRecord, 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 `__typenameliteral 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 isinstanceof 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 synthesizedGraphQLErrorcell.
Three things keep this a partition rather than an authored-order cascade, none of which needs a priority ordinal:
-
An optional
matchesmessage-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.matchesonly narrows a cell, it never splits one signal into two types (splitting the DATABASE domain is what the exactSqlState/VendorCodekeys are for; splitting GENERIC by message is the fragile pattern this disallows). -
Subtype overlap (a cell on
IntegrityConstraintViolationExceptionalongside one onSQLException) 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 |
|
|
field, 1:1, total |
R1 (the class suffix single-homed at |
seam (a): picks root / child / service strategy |
landed; the cross-class edges the fetcher bodies reference are rows of the fetcher-edge relation ( |
2 |
|
|
one method per projection unit: anchors, |
R1 (unit addresses minted by |
seam (b, c): reused + assertable |
landed; unmigrated launcher hosts read the same call emitter, so the literal has one home |
3 |
|
|
anchor (SELECT launcher) |
R1 |
seam (a, b): batched/direct dispatch + reuse |
settled ( |
4 |
|
|
dedup-by-class |
R2 |
seam (b): class-level reuse |
lift to R1 |
5 |
|
|
one glue method per |
R1 (class and method refs minted by |
seam (c): assertable |
landed; the lift closed on both ends (the entity layer, its |
6 |
join-path helper |
|
per join path |
R1 |
seam (b): reused |
settled ( |
7 |
|
|
per lookup field |
R1 ( |
seam (c): assertable |
landed; the root-host end lifted with R541 (Done) |
8 |
|
|
dedup-by-class |
R2 |
seam (b): class-level reuse |
lift to R1 |
9 |
|
|
per orderable field |
R1 ( |
seam (c): assertable |
landed |
10 |
Root Query unit (the root |
|
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 |
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 |
|
term of a |
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 |
|
wrap of a |
n/a |
resolved: folds into Projection as data; the |
closed with row 2 |
14 |
Inline lookup table-field arm |
|
wrap of a |
n/a |
resolved: folds into Projection as data, its rows helper a minted |
closed with row 13 |
15 |
Channel catch / early-return arms |
|
arm of fetcher body |
n/a |
folds into Fetcher (row 1) |
OPEN: assert the error channel independently? |
16 |
|
|
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
sourceandtargetfacts and dispatches; it is a view over facts, not an operation. Thenest-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
serviceCalloperation 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 |
|---|---|---|---|
|
table-bound |
Projection |
R1 (landed 2026-07-29) |
|
a |
Query unit / rows-method (3; root 10) |
R1 (root landed with R541) |
|
pagination args / |
applied within the Query unit (3, 10) |
with the query unit |
|
the |
join-path helper (6); lookup |
R1 (6, 7) |
|
|
Condition (5) |
R1 (closed 2026-07-29, R552) |
|
|
OrderBy (9) |
R1 ( |
|
|
Service-call unit (11) |
R1 (the launcher family’s service arms) |
|
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.wrapperis 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/@servicechild’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,
Ncolumn QueryParts.CompositeColumnField/CompositeColumnReferenceFieldand the arity-as-leaf-property retire. (Shipped by R508, 2026-07-21, on all three axes ; output, input, argument ; as the checked-invariant merged carriersColumnBackedField/ColumnBackedReferenceField/ColumnBackedArg/ColumnBackedReferenceArg, each carryingcolumns1..N with arity read offisComposite(). 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.) -
SplitTableFieldvsRecordTableField(the lineage trigger): both project the same keyed-re-query QueryPart.SplitTableFieldadditionally projects a key-projection QueryPart addressed to the parent anchor (its enclosing scope is a graphitron-generated SELECT it can impose on);RecordTableFielddoes 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’sSourceShape.TableforSplitTableField: 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 withRecordTableFieldis at the keyed-re-query QueryPart, not the source shape. (Shipped: R432 merged the pair toBatchedTableField; 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.
Bulkwas never a leaf variant in the first place, it was already thetargetListwrapper, 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.InputTypefour-arm permit: live (GraphitronType.java:365); its sibling rootTableInputTyperetired (R519). -
ArgumentRef.InputTypeArg.TableInputArg/PlainInputArg: live. -
The
InputFieldsealed family (ColumnBackedField,ColumnBackedReferenceField,NestingField,UnboundField): live; R589 retires `UnboundField’s demotion-target reading, the carriers dissolve with the input-side migration. -
HasInputRecordShapecapability marker: live. -
RootFieldintermediate 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/UnclassifiedFieldand their validator walks: retire per R589 (tombstoning replaced by violation facts;UnclassifiedFieldsurvives 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 theConditionCall/ExternalFieldCallsiblings will never be built; capture supplies their content as decoded relations. It retires when its remaining readers migrate. -
The
GraphitronFieldsealed parent: the end state is deletion of the hierarchy, not the umbrella’s planned rename ofOutputFieldtoGraphitronField; 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 |
|---|---|---|
|
field (1:1) |
the coordinate |
|
table-bound type (a fold) |
the type |
|
anchor (the SELECT launcher) |
the query-launching coordinate |
|
field / method |
the condition coordinate |
|
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;javacstitches 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$fieldsto join/condition edges readMethodRef.methodName()off a{className, methodName}model value (JoinPathEmitter); the type-condition readsGeneratedConditionFilter.methodName(). This is exactly thread F’s "core owns the name on both ends," already shipped for these edges.MethodRefis 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).
$fieldsis a literal at the definer (TypeClassGenerator.java:216) and a$$fieldstemplate literal independently retyped at roughly eight call sites (SelectMethodBody:112,InlineTableFieldEmitter:123,TypeFetcherGenerator:753,765,SplitRowsMethodEmitterin 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>Conditionwas read from the model at the entity end but recomputed asfieldName + "Condition"at the shim end: one name, two loci. The condition command closed it by dissolving the second locus rather than repointing it;GeneratedUnitsmints 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 |
|
field, 1:1, total |
|
Projection (root), Rows-method (child), Bean/Record, OrderBy |
class R2, method R1 |
Projection |
|
type-bound fold |
|
Projection (recursive; cyclic), Condition/Join |
R1 ( |
Rows-method |
|
anchor (SELECT launcher) |
|
Projection, Scatter, InputRows |
R1 |
Scatter |
|
dedup-by-class |
|
leaf |
R2 |
Condition |
|
one glue method per |
|
Join |
R1 ( |
Join |
join-path helper ( |
per join path |
|
leaf |
R1 |
InputRows |
|
per lookup field |
|
Join |
R2 |
Bean/Record |
|
dedup-by-class |
|
leaf |
R2 |
OrderBy |
|
per orderable field |
|
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/ProjectionCommandsrows (the former per-armInline*Emitterclasses, dissolved). -
SQL sub-SELECT fragments (shared):
JoinPathEmitter,ArgCallEmitter,RoutineCallEmitter(R435), theLookupRowsvalues-join family, andFkTargetConditionFilterrendered viaConditionCommands/ConditionGlueRenderer(R552). -
SQL anchors (launch a SELECT, call
$project):SplitRowsMethodEmitter,MultiTablePolymorphicEmitter,RootLauncherRenderer(plus the root and lookup paths inTypeFetcherGeneratorandSelectMethodBody). -
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+RecordTableFieldmerged intoBatchedTableFieldand the lookup twins intoBatchedLookupTableField, each gated on a storedSourceShapewith a totalKeyLift(the split arm carriesFkColumns); the parent-projection containment check named under Query anchors and the two flows shipped first (ParentProjectionContainmentCheck, keyed onBatchKeyField+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 twoBatched*leaves. The fetcher fork initially survived inside one seam gated onsourceShape; 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
leafReconstructsFromCoordinateas "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 retiringdispatchPerformsReFetch; 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 |
|---|---|
|
|
|
|
|
the source-side key provenance |
|
the discrimination fact (type recovery, |
|
the discrimination fact (exception-domain partition) + operation-side |
|
the locator (output) / column binding (input) / |
|
the locator’s typed-jOOQ-field arm |
|
the |
|
|
|
|
|
|
|
|
|
|
|
the contained connection unit (R13): authored at the filter input’s member coordinate, resolved use-site onto |
|
|
|
the operation address (split / new-query anchor) |
|
node facts ( |
|
enum facts ( |
|
the column’s |
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:
-
Transform-definition (the read-side "transform"). Resolved (this session). There is no transform axis: the read is a locator plus references to
Columnand node facts; the scalar / enum conversions are column facts lifted at the boundary, and@nodeIdis node facts. See Reading the source object / Node facts / Enum facts. -
Table expression and the join path (
@routine). Resolved (this session).resolvedTablestays the table class;tableExpr(armsCatalog|RoutineCall) materializes a table node.@routineis aRoutineCallnode that can sit anywhere, the projected terminus (a function-backed query), an intermediate, or the root FROM-entry, in the@referencepath: a join step is(tableExpr target, on?)withonaColumnPairs | Predicate, and joins adjacent to the FK-less routine are keyed by a name-matchedkey:or acondition:. The@referencepath 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/@referenceco-occurrence composes the chain, so no@oneOfpath-element surface is needed at all. -
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 isRecordClass(type survives, distinct tables) orDiscriminatorColumn(value → type)(same-table participants, or aUNION ALL-erased read whose synthesized__typenameonly undoes the erasure), gated by the discriminability invariant (same-table-without-discriminator is a build error). Distinct from the deferred producer-polymorphism. See Discrimination. -
Error mapping (
@error). Resolved (in model). Not a new axis: it is discrimination in a second signal domain. The polymorphic value is a caughtThrowableand the recovered types are the payload’s@errortypes; an@errortype 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 ofDiscriminatorColumn. It stays a partition (no authored ordinal):matchesis 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 aredactcomplement. The one genuinely new sub-fact is operation-side,errorGuard(channel, interned handlerSet), on throwing operations. See Discrimination. -
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) orLift(lifterRef)(class-backed DTO, authored via@sourceRow). It changes only where the source-side values come from, the columns are real catalog columns and@referencenavigates 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. -
@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)
-
@routineand the join path. Resolved (this session): see The join path. A routine is aRoutineCalljoin-target node in the@referencepath (or the root FROM-entry), joined by a name-matchedkey:or acondition:. 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@oneOfpath element; it is order-significant, repeatable@routine/@referenceco-occurrence, the field’s directive list read left to right being the linearized join graph (ReferenceElementuntouched); (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@routinecode allowed only the projected-result shape, with no composition.) R435 has since shipped (Done; seechangelog.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.$fieldsat 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` inTypeClassGeneratorat resolution time; since re-platformed ontoplan/ProjectionCommandsrendered byrender/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 acorrelationthat 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/RecordTableFieldcollapse 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(collectRequiredProjectionat resolution time, now theProjectionCommandsopt-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
@classifiedverdict generalizes from one triple to the(DataFetcher, QueryPart*)decomposition. Resolved by the normalized schema: the directive asserts the coordinate’ssourcefact, itstargetfact, and a set ofoperationrows, 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-modelH2 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
Singlevalue-gating semantic. Resolved (the resolved-table section): aconditionkeys onresolvedTable, and its semantic forks ontarget.wrapper(List= row-set filtering,Single= value-gating). Open residue: theSinglevalue-gating semantic itself, the predicate’s ON-clause placement under aLEFT JOINand 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’sColumnBackedReferenceFieldis onlySingle(Column)). Settle whether it lands as a wrapper variant of the reference fact reusing the to-many table-field machinery (aChildsource, 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 thejoin`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 `joinstanding 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
Columnfacts (scalar / enum coercion, entailed by the column type and lifted at the graphql-java boundary) and node facts (the@nodeIdcodec, entailed by a node’s key definition; the key projection is the read-side locator, and a non-identity-carrying path rides thereferencefact’sjoin/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.