How Graphitron is built: six axioms, each with corollary principles, and every principle ends with
an Enforced by line naming what fails when it is broken ; the compiler, a named meta-test, a
build tier, or the honest gap label "review only". Two theories give the axioms their spine.
Functional core, imperative shell fixes the topology: untyped external inputs (wire formats,
reflection, jOOQ) are confined to a thin boundary that decodes inbound and encodes outbound, and
the interior is pure derivation over typed facts. Normalization is the design discipline in that
interior, and it governs both ends of the pipe. It shapes the authoring surface ; the SDL author
states each fact once, at its grain, and Graphitron derives the rest ; guides how the model is
discovered and extended, find a concept’s grain and natural key first ; and keeps the
model a normalized fact base: each fact captured once at the boundary, everything downstream
(generation, validation, the LSP) a derived view. Because the model is rebuilt from source every
build, each such view is a materialized view, never a copy that can drift from its source ; that
copy is the drift smell, and it recurs at every scale below. Strategic principles live at
graphitron-principles.md; the typed-rejection narrative at
Typed rejection; additive-then-cutover change discipline in
roadmap/workflow.adoc.
Decide once, at capture; carry the decision and its provenance as facts
Before implementing a feature, ensure the model carries what the feature needs ; pre-resolved,
ready to consume. Capture transcribes every external input once into the fact store’s keyed base
relations ; provenance kept, not overwritten ; and the derived strata carry each decision as views
over that base, so the resolved answer never replaces the authored fact beneath it. During the
strangler window the classification walk (GraphitronSchemaBuilder) is the same boundary in
transitional form. Every consumer receives "what to do", never "what to interpret"; the modeling
discipline behind the store is The fact model.
Signs a model type needs more pre-resolution:
-
A generator switches on a raw string, or recomputes a derived name from a field name.
-
The same multi-arm type switch recurs across multiple generators.
-
Generation and calling are conflated in the same model type.
-
A generator branches on a predicate over pre-resolved data ; the decision was not resolved, only its inputs were. Rule of thumb: if two consumers evaluate the same predicate over a model field, the branch belongs in the model.
Sealed hierarchies over enums for typed information
When different variants of a concept carry different data, use a sealed interface, not an enum
with a shared field set: a sealed record hierarchy gives each variant exactly the fields it needs,
and every switch that misses a new variant becomes a compile error. CallSiteExtraction is the
exemplar ; some arms carry nothing, others exactly the references their extraction strategy needs ;
read the type for the current arm set.
Enforced by: the compiler ; exhaustive switches over a sealed hierarchy break when a variant is added.
The capture boundary is a containment invariant
"Decide once" is also a statement about where raw external types may live at all. At capture
the containment is structural: a relation stores values, so no live Table<?>, ForeignKey,
Class<?> or graphql-java schema object crosses into the store. SdlFactCapture and its
collaborators read each handle down to rows.
Off capture it is a convention: the rewrite and emit stages legitimately hold a registry or schema,
so the rule is direction, not a holder roster. A consumer of a classification switches on captured
values, and reaching back to a live object or a reflection Type tree for a fact the store could
hold is the smell. An undecoded fact earns a column or a component, extracted at the boundary.
Enforced by: the store’s shape at capture (relations hold values, never live handles); off
capture, review against the reads themselves (TypeDefinitionRegistry, GraphQLSchema,
java.lang.reflect.Type, getGenericReturnType).
Shape the type as precisely as the fact allows
Give each fact the narrowest type the classifier can guarantee. A field record component declares
the narrowest, not the broad sealed-interface root ; a return type that is always table-bound
declares ReturnTypeRef.TableBoundReturnType directly, so consumers know it without a runtime
check. A complex outcome gets its own sealed sub-taxonomy, not raw strings ; TableRef for the
resolved table, ColumnRef for the resolved column. A new sub-taxonomy earns its place with a
one-line note on what it carries that a sibling cannot ; otherwise it is a field on an existing
record, and at milestone boundaries audit which could collapse.
Enforced by: the compiler where the narrowed component type is the contract; review for a new sub-taxonomy’s justification note and the milestone collapse audit.
Classification and validation gather facts; the gathering stays out of the model
"Carry the decision as a type" says what the model holds; this corollary says what it must not.
Classification gathers facts and populates the model; the scaffolding it carries while gathering ; a
builder-internal sealed hierarchy, a re-classification of the same argument in each output field’s
context, a traversal carrier threading call context and collecting located violations as it walks
the schema ; is an implementation detail, discarded before the
model and invisible to every downstream view (generator, LSP). Validation is the same activity
pointed the other way: it gathers facts too, reifying each missing or conflicting fact into a
located violation the build acts on later (that violation’s typed shape lives under "Builder-step
results are sealed" and "Rejections"). The smell either way: gathering
scaffolding leaking into a model type, or a downstream view branching on a builder-internal type
instead of the landed fact. A third form is inter-pass, not model-vs-scaffolding: several gathering
passes coordinating implicitly through duplicated hardcoded skip-lists (buildFilters() skipping
the pagination arguments buildPaginationSpec() claims, by the same hardcoded names) instead of
each projecting exhaustively off one classification ; the lists must agree, nothing binds them, and
a new argument type falls through both silently.
Enforced by: review only ; a downstream view referencing a builder-internal classification type, or two passes sharing a hardcoded skip-list, is the tell.
Builder-step results are sealed, not strings or out-params
Every builder-step lift returns a sealed Resolved; rejection is a typed variant with a stable
LSP code, never a string or out-param. The full narrative lives at
Typed rejection. In fact-base terms, rejections are facts too: located
violations asserted once and rendered into views ; the build log, the LSP ; never prose composed
at the detection site.
Enforced by: the compiler on the sealed results; SealedHierarchyDocCoverageTest pins the
permit-to-doc mapping for the Rejection taxonomy.
Orthogonal facts are independent axes
Assert what nothing else carries; derive the rest from that base ; and never let a derived fact become a copy maintained apart from its source. When a concept’s variants multiply, the usual cause is independent axes spliced into one identifier: the permit set becomes the cross-product of its axes, and adding a value to any axis multiplies the permits below it. Find the concept’s grain and natural key, carry each axis as its own slot or sealed sub-interface, and either compute cross-axis views at the read site or materialize them from the base. The drift smell at field scale is the spliced identifier: two axes fused into one permit name, so the cross-product falls to hand-maintenance rather than derivation. The model’s durable statement is The fact model.
The DataLoader-backed source side is the worked example: key shape, body input contract, row count, and loader registration are independent axes, and each dispatch site reads off whichever axis it forks on (Dispatch axes narrates the split). The smell to watch for: a single shared accessor whose meaning depends on the variant.
Enforced by: the compiler where cross-axis invariants live in compact constructors; review at model-design time otherwise.
Capabilities reify an orthogonal axis; sealed switches fork on identity
A fact true across a subset of variants is an orthogonal axis: reify it as a capability interface
that exposes the fact uniformly (SqlGeneratingField.filters() over variants that carry filters in
structurally different places), not as an instanceof chain re-enumerating the members. A view that
forks on identity uses a sealed switch. Membership in a capability is itself a derived fact, so the
axiom above governs it: safe when it is single-sourced ; mechanically assigned by the classifier or
pinned by a test ; and a drift risk when it is a hand-declared marker nothing binds to the base
facts it should track. Capabilities relocate exhaustiveness bookkeeping off the sealed switch rather
than eliminating it.
Enforced by: the compiler for the accessor contract and the sealed-switch half; capability membership completeness is review only ; a variant that should implement a capability but omits it is a silent skip, so a membership meta-test is candidate roadmap material.
Directives carry only what the SDL author needs to say
Where the ingress’s authoring-surface principle, state each fact once at its grain, is enforced at
directive-design time. Directive arguments are flat scalars whenever the directive site already
disambiguates the axis; an input-object wrapper is justified only for several genuinely-orthogonal
pieces of information at once. @field(name: String!) is the worked example: the site (field,
input field, argument, enum value) tells the classifier which axis is bound, so the directive
carries only the name ; the axis is structural. The smell: an
input wrapper most callsites fill in two-of-four slots on, whose failure surface widens from "the
named thing didn’t resolve" to a cross-product of missing/inconsistent-slot errors
(ExternalCodeReference is the existing case new directives should not lean on).
Enforced by: review only, at directive-design time.
One model, many views
Code generation is the narrowest view of the model, not the model itself. Every consumer reads
views over the one base; none owns a private model, and each view’s coverage guarantee is an
exhaustive gate over its own sealed vocabulary (GeneratorCoverageTest,
TriggerDispatchMatrixTest): a new variant breaks the build until every view covers it. The drift smell at model scale is a consumer-side shadow taxonomy; the re-sourcing
invariant is The fact model.
Enforced by: the coverage gate at each seam.
Boundaries decode and encode; the interior is typed
Opaque wire formats (Relay NodeId base64 strings, Relay cursor strings, federation _Any
representations) decode at the DataFetcher boundary into typed column tuples; the projection layer
encodes back into wire format only at the same boundary; variants representing the wire shape
don’t survive in the model.
For any opaque wire format, classify the failure mode (skip vs throw) and the direction (encode
vs decode) at the boundary, never below it. The worked example: nine "the model says this
is a NodeId" markers were retired in favour of decode and encode facts carried at the boundary
slots where each happens (see roadmap/changelog.md for the carrier-by-carrier story); cursor and
federation _Any handling already followed the pattern. The smell: a "this is a NodeId" or "this
is a base64 cursor" marker spreading through the model ; a bypass around classified information
the boundary already carries.
Enforced by: review only; the regression surface is pipeline-tier (a wire-shape carrier reintroduced into the model has no typed home to land in).
Model metadata over parallel type systems
When the model already carries typed information, runtime data formats derive from that metadata
rather than inventing a parallel type system. The cursor format is the exemplar: each cursor
column’s jOOQ DataType is already known, so encode/decode goes through
field.getDataType().convert() instead of a hand-rolled type-tag system ; the column metadata
is the type information. A parallel type system in a runtime format is the drift smell at the
format boundary: redundant, and diverges.
Enforced by: review only.
Wire boundaries are typed adapter / composer pairs
Where the generator emits a method that crosses the wire-format boundary, it emits it in pair
with a composer: the adapter decodes the wire shape into typed values, the composer does the work,
and the composer’s signature is exactly the shape the adapter yields ; the boundary is the pair,
not the adapter alone. The generated QueryConditions.<method>(Table, env) forwarding to the
user-written <X>Conditions.<method>(Table, …) is the worked example. The smell is asymmetric
typing across the pair ; most often the adapter erasing type information the composer needs (an
arity-erased RowN where the decoder produced Row<N><T1, …, TN>; the fix has shipped),
turning would-be compile failures into DSL-runtime surprises.
Enforced by: the graphitron-sakila-example compile where the pair’s signatures meet; review
for the symmetry itself.
Every invariant has an enforcer
An invariant exists only while something fails when it breaks. Three corollaries below name where a fact is enforced ; at the validator, at the emitter’s type or tier, at the pipeline tier ; and two turn the same discipline on this document itself. Each names a distinct enforcer that runs at build time or earlier ; a runtime cast is never one, failing on a real request days after the build passed. This axiom enforces against the drift smell: a fact restated with no single enforcer (a second dispatch set, a defensive cast, an unguarded census) drifts silently. The worked example is an emit-side allow-list that drifted from the arms the emitter implemented until it was deleted and the invariant re-sourced. A review-only label is an invitation: filing the meta-test that pins it is roadmap material.
Rejections: validator mirrors classifier invariants
Every classifier decision that implies a generator branch must fail at validate time if that
branch is unimplemented: the validator reads the same dispatch sets the generator does, so an
unsupported classification is a build-time error, not a runtime
UnsupportedOperationException. The dispatch state is a four-way disjoint partition over every
GraphitronField sealed leaf: TypeFetcherGenerator’s `IMPLEMENTED_LEAVES,
NOT_DISPATCHED_LEAVES and STUBBED_VARIANTS.keySet(), plus the projected bucket derived from
the projection producer’s own membership declaration (ProjectionCommands.CONTRIBUTION_MINTING_LEAVES
minus the dual-arm kinds; safe to derive because the test below pins the partition and the
projection census test binds the declaration to observed minting), and ValidateMojo fails the
build on stubbed variants. The rule extends to every new
classifier invariant: no generator-side invariant goes unchecked at validate time.
Enforced by: GeneratorCoverageTest.everyGraphitronFieldLeafHasAKnownDispatchStatus (partition
exhaustive and disjoint); ValidateMojo fails the build on stubbed variants.
Acceptances: classifier guarantees shape emitter assumptions
The reverse direction: a classifier acceptance lets an emitter assume narrower shapes, so emitted
code reads as tight as hand-written ; no defensive casts, no wildcard locals, no instanceof
guards. The contract anchors on three layers: type-system narrowing at the producer (a record
component, a return type, a sealed sub-variant ; once in the type, mechanically enforced),
pipeline-tier tests pinning the end-to-end shape, and the graphitron-sakila-example compile as
cross-module backstop. A defensive runtime cast or a var-typed local abandons the guarantee ;
it fails on a real request, days after the build passed. Candidate type-system lifts that would
carry current prose contracts structurally are method type-token
threading and ColumnBackedField.parentTable. To record a producer-consumer linkage the type system can’t
carry, use a javadoc {@link} from consumer to producer ; build-checked by the javadoc
reference gate. Rule: if you relax a producer’s check body, audit every emitter site that
consumes the corresponding shape, in the same commit.
Enforced by: the type system where the contract is lifted; the javadoc reference gate for
the {@link} itself; the pipeline tier and the graphitron-sakila-example compile otherwise.
Behaviour is pinned at the pipeline tier and above
Behaviour is asserted at the SDL to classified model to generated TypeSpec pipeline layer; new
features earn a pipeline test first. Above it, the graphitron-sakila-example compile pins type
correctness and PostgreSQL execution pins behaviour. Code-string assertions on generated method
bodies are banned at every tier: they test implementation, not behaviour, and break on every
refactor ; the compile and execution tiers replace them. Tier names, locations, and the decision
rubric: Test-tier guide.
Enforced by: the tiers themselves, on every -Plocal-db build; the code-string ban is
review-enforced at test-review time.
Principles are stated at altitude
An unguarded inventory in this document ; an arm list, a file census, an occurrence count, a
compliance roster ; reads as authoritative and rots silently. A principle names the rule, one
canonical exemplar, and the smell; an inventory appears only when a named live test pins it (the
dispatch partition under "Rejections" is the pattern). Anything enumerable is first a materialized
view, regenerated and --verify-gated on every build (supported-directives.adoc is the
precedent); hand prose is the fallback for rationale only. Exemplars are chosen on
stable surfaces: an exemplar that needs a forward note about its own demolition is the wrong
exemplar.
Enforced by: review; the named-test requirement is the rule itself.
Documentation names only live tests/code
Every prose claim about code takes one of three honest forms: anchored on a checked reference
(a {@link} the reference gate verifies), pinned by a named live test or type, or written at
intent altitude ; the why and the shape ; so it survives mechanical refactors. Orientation
javadoc is the blessed intent-altitude case: a class-level on-ramp (scope, entry points, model
output, doc pointer; `GraphitronSchemaBuilder’s top comment is the exemplar) orients without
narrating mechanics. The smell is narrow: line-by-line narration of a body, or prose
paraphrasing a mechanism ; a hand-maintained caller census ; true when written, silently
falsified as the code moves. Prefer deletion over rewrite.
Enforced by: the reviewer gates (Spec to Ready, In Review to Done); doc-coverage meta-tests
where the surface is closed (SealedHierarchyDocCoverageTest and its siblings);
RetiredVocabularyGuardTest for prose naming retired vocabulary.
Generated code is a consumer artifact
Emitted code is read, breakpointed, and stack-traced by developers who have never seen the generator, and it compiles on the consumer’s toolchain. Optimise emitted code for the consumer’s legibility, not emitter-side brevity; and keep everything that ships to consumers valid Java 17, whatever Java 25 features the generator itself uses. This is the readability-and-portability companion to "Acceptances: classifier guarantees shape emitter assumptions": that corollary keeps emitted code tight (no defensive casts, no wildcard locals); this axiom keeps it legible and consumable.
Readability rules
A generated method that throws on a real request must produce a stack frame, a line, and locals that a developer who has never seen the generator can reason about. Concrete rules for any code a generator emits:
-
Explicit types, never
var; the reader does not have the generator’s context. -
Meaningful local names (
nodeId,row,key), never throwaway pattern-binding noise (_s,_r). -
Statement form over expression tricks. No deeply-nested ternaries, no throw-inside-an-expression contortions; a developer cannot breakpoint a ternary arm. When the output must be an expression, lift the body into a named private helper (per Helper-locality) so the call site stays an expression and the body is readable statements.
-
No
-prefixed Java identifiers in emitted code. Every name in scope is knowable at generation time, so a readable deterministic prefix (arg_<name>) always beats a blanket. The-prefix legitimately survives only as string literals (synthetic SQL column aliases likesort; spec-defined external names like jOOQ’sNODE_*and federation scalars); the identifier-vs-literal discriminator and its full semantics live onGeneratedSourcesLintTest.
The smell: an emitter building a CodeBlock of nested ? : with Object casts and _x locals ;
written for the emitter author’s convenience, not the consumer’s. (The NodeId-decode instance is
cleaned up; the @condition arg-extraction instance is not.)
Enforced by: GeneratedSourcesLintTest.emittedSourcesDoNotUseVar and
GeneratedSourcesLintTest.emittedSourcesHaveNoDunderIdentifiers (plus that class’s sibling
lints); review for statement form and naming.
Generator Java 25; generated output and shipped runtime Java 17
Three categories, three floors. Generator implementation may freely use Java 25 features.
Generated source files must be valid Java 17: consumers compile Graphitron’s output with their
own toolchain, so nothing emitted may require 21+ ; no switch patterns, no sequenced-collections
API. Hand-written runtime artifacts consumers depend on (graphitron-jakarta-rest is the first
instance) must also target Java 17 ; a runtime jar on the consumer’s classpath keeps the same
floor as the generated sources. When adding code, ask which category it is in before reaching for
syntax.
Enforced by: the parent pom’s requireJavaVersion enforcer (generator floor);
graphitron-sakila-example compiling with <release>17</release> (emitted-syntax ceiling);
graphitron-jakarta-rest’s own `<release>17</release> main compile (runtime category).
The emitter-conventions catalogue (return types, selection-aware queries, error quality,
DSL.val column binding, DTO-parent batching, helper-locality) is reference material for emitter
authors: Emitter conventions.
Constraints
Facts with existing enforcers, recorded here so the axioms above stay principles:
-
The repo root
pom.xmlis a single self-contained Maven reactor:mvn installon a clean local repo builds every module (the root pom’s<modules>list is the live inventory) with no dependency outside the pinned third-party set. The legacygraphitron-parentgenerator is retired;graphitron-maven-pluginis the consumer entry point. -
This document is a shared context cost: agents and reviewers load it on every design consult, so it budgets itself at 3,500 words. An addition that pushes past the cap must displace something. Enforced by:
DocSizeBudgetTest.developmentPrinciplesStaysUnderBudget.