The generator’s model of record is a relational fact base. The store’s DDL (graphitron-model.sql in graphitron-model) is the model’s what: which relations exist, their keys, their constraints, and the comment on every one of them, rendered browsable as the generated schema reference, one page per relation-name family, generated at build from those same comments. This page is the why: the discipline that shapes those relations and the invariants that keep the model debt-free while the transitional classification walk drains. Where this page and the DDL disagree, the DDL wins. What runs when is the Pipeline overview; this page owns the rules a model change must obey. Every claim below names its enforcer, the live test or gate that fails when the claim breaks; a rule without one is not on this page. A gentler first contact with the naming discipline, examples and metaphors included, is Naming the row.

The discipline has an executable form

The facts are typed, keyed relations in an embedded H2 database booted from the DDL, read and written through jOOQ classes generated from that same DDL. That closes the usual objection to a SQL layer, that it makes the model stringly-typed and moves exhaustiveness to runtime: the generated classes are the containment, graphitron-model builds before the generator core, and a DDL edit fails javac in every consumer that touched the changed relation. There are no schema migrations and no persisted state of record; a warm store is reconciled per source hash and an incompatible upgrade opens a fresh file, so compile time is the only compatibility surface the schema has, and changing the model is editing the DDL and following the compiler.

Referential integrity splits by what it can enforce. Capture-structural edges, where the walk writes the child while standing on the parent (a field row’s declaration site, an application’s host directive), are declared `FOREIGN KEY`s. A reference the author spells by name deliberately carries no FK and is a detection query instead, because a dangling author reference is a diagnostic to report, never a capture bug to crash on.

Enforced by: the compiler over the generated jOOQ surface; FactSchemaGateTest holds the store’s own invariants (total comment coverage, dense ordinals, verbatim-transcription twins for every decode, the graph-partition rules), so a failure there is a capture bug or a DDL defect, never an author error.

The key discipline: natural keys, rendered coordinates

Base relations key schema elements by the spec grammar’s own Name columns, per relation and all non-null: graphql_field keys (graph_name, type_name, field_name), and its kin follow the same shape. There is no surrogate id anywhere in the DDL; where a tie-breaker is needed it is an ordinal inside the natural key, never decoration on a counter. The keys are what the model joins and scans on: every field of a type is a prefix scan on the type name, and every graph-partitioned key leads with graph_name.

The canonical coordinate string (Foo, Foo.bar) appears in the store only as a rendering of those columns. The two stored coordinate columns, on the duplicate-declaration overflow (graphql_duplicate_declaration) and on the diagnostic read surface, each say so in their own comment: a rendered value computed from the stored columns, never the key. A rendered key cannot drift from the columns it names, which is what makes it safe as a stable id to hand outward.

Authored facts are definition-keyed; derived bindings are use-keyed. An input field’s authored facts live at its own member coordinate, the only key available where the author’s cursor sits, because one input type is consumed by many fields. The use-site resolution, which table an input binds against and through which occurrence path, depends on the consuming field, so it is a derived join over definition and consumer, shipped as the materialized intent_input_occurrence_path pair rather than as a dotted-path key.

Enforced by: the PRIMARY KEY`s themselves, at capture; `FactSchemaGateTest.everyRelationLeadsWithItsPartitionDimension, everyGraphKeyedRelationReachesTheAnchor and aRunWritesOnlyUnderItsOwnGraph for the partition half; FactSchemaGateTest.commentCoverageIsTotal keeps the rendered-not-stored statements written on the columns that carry them.

Facts, not leaves

Each fact is an independent functional dependency of its coordinate, found by its own walk: where the source object arrives, what the field returns, which operations it triggers, whether the value crosses a table. A capability is added by adding a fact relation, never a new leaf type, because facts add where leaf types multiply: welding independent axes into one classified leaf produces the cross-product of the axes, and the leaf zoo is exactly the denormalized view that multiplication built (repeating groups for multi-column values; duties welded onto a leaf that functionally depend on another coordinate’s query). The sealed leaf model the classification walk still produces is the transitional producer surface of the strangler migration, drained rather than extended; a new fact lands in the store.

Enforced by: FactCaptureAgreementTest.everyRelationIsRegistered, whose driver enumerates every generated relation and fails on one with no registered agreement source, with a reverse leg failing on a registration the DDL no longer declares, so a fact relation can neither arrive unchecked nor linger as a phantom.

Name the row, not the question

A relation named for a consumer’s question inherits that question’s shape, and the damage is not visible from inside the work. Its grain becomes whatever the caller’s return value was. Its derivation absorbs every special case the caller had, arriving as one relation with a procedure behind it rather than a fact with a grain. And because no independent source states the answer, the only available oracle is the code being replaced, so a migration’s own predecessor becomes normative and whatever bugs it has become pinned invariants. The three symptoms travel together, and each one looks like a local modelling choice on its own.

The check runs before the DDL and costs one sentence: say what a single row asserts, without naming a consumer, a generator pass, or an existing class. "This classfile declares this supertype through this clause" passes. "This class offers this slot under this name" passes. "The class the resolver would bind to this type" fails, and it fails while the fix is still free. The sentence has to survive at the column grain as well, and a column encoding a set is where it stops surviving: the row then asserts something about several things at once, so nothing said about one subject finishes it honestly, which is the rule under "Derived reads are views, not stored facts" read one grain down.

What survives the check is usually several facts where the question suggested one, each anchorable against its own source and composable by the next consumer, which wanted a different combination. The relation the question asked for often survives too, radically thinner, once the clauses that were really separate observations have moved out beside it. Watch for the ones that were never facts at all: a filter one caller applies, and an agreement between two facts, which is a detection rather than a step.

The corollary is about oracles. Fidelity to a predecessor is evidence, not a specification. Where a migration needs the comparison, the predecessor writes facts and the comparison is a shadow between two relations, which runs over any corpus, answers while the replacement is half built, and drains when the predecessor retires. The walk_ family is the shipped case, with its removal criterion in its own family header. A test asserting equality with old behaviour has none of those properties and quietly installs the leaf zoo as the standard the fact model is held to.

Not mechanically enforced: the check is a sentence said out loud before the DDL exists. The gates catch downstream shapes (an unregistered relation, a derivation with no anchor, a stored value that should be a view), never the naming that produced them.

Provenance: pick the shape per fact

Do not assume a fact’s origin is a column on the fact. Authored and inferred values often come from independent walks (the directive decode versus the catalog or classpath), and when they do the natural form is separate relations coalesced by a view, not one relation with a provenance tag. When one value fills one slot, a sparse authored column plus a default rule suffices. Either way the resolved value is always a view over the populations, never a stored merge.

The claim stratum is the shipped separate-relations case: intent_authored_field_claim and intent_authored_type_claim union one arm per claiming graphitron_ relation, intent_column_match_claim carries the structural reading with its join witnesses as payload, intent_resolved_field_claim is the reduction, and intent_authored_claim_conflict detects coordinates carrying contradictory claims. That relation is total over the authored claims and carries no population filter: an authored contradiction is a contradiction wherever it sits, so each consumer joins the population its own question needs (the build-error surface joins the classification domain, because only the emitted surface can fail a build; the editor’s diagnostic arm reads the rows ungated). The conflict reduction lives in the view’s SQL; AuthoredClaimConflicts mints the typed rejection from the closed verdict vocabulary and the coordinate’s own claim rows, and applies the build-error population. The message is minted rather than derived because its naming order is the claim enum’s declaration order, which is not a captured fact of any graph and so is no view’s to express; a capture-cadence writer stores the same rejection in intent_authored_claim_rejection for the diagnostics surface to read as a plain column, which is why that surface’s claim-conflict arm looks like its captured siblings instead of assembling a sentence in SQL. The one-slot case is graphitron_node: type_id holds what the author wrote and stays null where they wrote nothing, so a surface reading the relation sees authored data only, by design, and the resolved value falls back to the type-name rule the relation’s own comment names as a derivation.

The consumer split is which population each reads: code generation wants the resolved answer, the editor gives feedback only on what the author wrote, the knowledge surface cites the authored source and reports the resolved value.

Description looks like the case for a column, and it splits. Where the walk that produces the row reads the description in the same pass (the SDL docstring, the catalog COMMENT), the description is co-sourced with the entity and belongs on the entity’s own relation. Javadoc is the counter-case, and it was once listed here as though it were the same thing: it comes from a parse of the consumer’s sources, not from the bytecode scan that produces jvm_class, and it moves when a file is saved rather than when a build runs. Two walks on two cadences, so its own relation in the java_ family, joined by name, and never a column on the census. The discriminator is not whether the two values describe one subject; it is whether one walk read them both. The same question, "where did this come from", gets opposite shapes depending on whether the origins are independent walks; the instinct must be "pick", not "always a column".

Enforced by: FactCaptureAgreementTest registers the whole intent_ stratum under its derived arm, with per-view anchors pinning each derivation against hand-written expectations the view cannot produce by construction. An anchor sits at the layer whose question it asks. Agreement with the transitional walk stays beside the walk (AuthoredClaimConflictsTest, ColumnMatchShadowTest, DemandShadowTest, InputOccurrenceShadowTest in rewrite/derive) and retires with it; what a view returns given rows is pinned in graphitron-model, the module whose DDL declares it, against a store seeded row by row (ColumnMatchClaimTest, ClassMemberSlotTest, AuthoredClaimTest, DemandRuleTest, InputOccurrenceOverrideTest, SeparateFetchRuleTest, TypeBackingSeedTest, TypeBackingTest and their siblings in model/intent). A view reading a fact about the outside world keeps a third anchor beside the crawler that produces it, asserting that real inputs reach the relation in the shape the rule reads: ClassMemberSlotScanTest is that anchor for the classpath census, and it is one case, the rule’s own edges being the seeded half’s business.

The three strata: capture, derivation, queries

The store has three strata. Capture transcribes facts from a corpus. Derivation computes further facts from captured ones. Queries read facts to serve a goal. Which one a row belongs to is decided mechanically, by a single test: a row that can be recomputed from captured facts alone is a derived fact and must not be captured. Stated once, that test settles the cases the next section argues one at a time, and it settles the ones no section has reached yet.

The numbered form is this axis and only this axis. "Stratum one", "stratum two" and "stratum three" mean the three above; an unnumbered "the X stratum" means something else, a section of the DDL or a layer inside one family. Without that rule a reader meeting "the diagnostics stratum" a few paragraphs from "stratum one" counts four. This page carries four unnumbered uses, and re-reading them under the rule is the whole of what the numbering does to them: "the claim stratum" and "the whole intent_ stratum" name a family, and "the diagnostics stratum" (twice) names an arm set that spans three of the buckets below. A fifth use, "the derived stratum", was stratum two under an older name, so the numbering replaced it outright instead of re-reading it. Take that set from the file rather than from this sentence; a count stated in the section that argues against unguarded counts is the wrong thing to trust.

A relation’s stratum is decided by what its rows are a function of, never by what program computes them. A materialization whose producer is a Java walk is stratum two if its inputs are captured facts, and materialization is sanctioned above where a view cannot serve. Saying this in the same breath as the assignment matters, because "the decoded reading is a derivation" otherwise reads as a demand that a thousand lines of decoding become SQL, which is not what the stratum claims.

A rule reading two corpora is stratum two, and this is the one corollary of the recompute test that a gate can close. A crawler answers for a corpus that exists independently of the others: the .graphqls files are what they are whether or not a jOOQ package was generated, and the generated classes are what they are whether or not any schema mentions them. So the rows a crawler writes about its own corpus must not vary with another corpus’s contents, and a rule whose answer needs both is a derivation over their captured facts rather than a decision either crawler may make. Federation’s key synthesis is the worked case: it fires on nodehood, and nodehood conjoins the SDL’s claim (@node, or @table plus implements Node) with node-identity metadata a generated jOOQ class publishes. Run inside the SDL walk it made an unchanged .graphqls file write different graphql_ and graphitron_ rows when a generated class changed, which is stratum two running inside stratum one and landing its output where nothing tells it apart from a transcription.

No foreign key could have caught that, which is why the rule needs a gate of its own rather than a constraint. A key constrains references, and the schema already refuses to model SDL-to-jOOQ resolution as one: there is no graphql_ to sql_ edge anywhere, and a @table(name:) row holds a string a crawler transcribed rather than a pointer at sql_table. A cross-corpus read inside a crawler adds no reference at all. It changes which rows exist, which no constraint expressible in the DDL can reject.

Enforced by: CaptureCorpusIsolationTest captures one registry twice, once with the jOOQ catalog and once without, and requires every graphql_ and graphitron_ relation to hold identical rows across the two. The relation set is enumerated off the generated model by family prefix rather than listed, so the next capture-time cross-corpus read fails the gate without being named in it. Note the scope precisely: the gate fires on the cross-corpus subclass of a recompute violation and on nothing else, so a capture-time derivation whose inputs all sit inside one corpus still passes it. The surviving @asConnection expansion is exactly that shape, which is why the inversion the next paragraphs name is a live inversion under a green suite.

Which stratum each family is in

meta_family is the census. It rosters the relation-name families as constant rows, is closed against the observed relations in both directions by the schema gates, and renders one reference page per row; what follows reads that roster rather than restating it, and where the two disagree the roster is right. The buckets:

  • Stratum one, transcription. the declaration relations of graphql_ (the generic directive definitions and their applications at all five locations included, with argument values), jvm_, sql_, and java_. Each holds what a walk read from one corpus. javac_ is here too, and is the only stratum-one family whose corpus the run itself produced rather than read from the consumer.

  • Stratum two, derivation. intent_, and graphitron_. The graphitron_ relations are decodes of the generic directive applications stratum one transcribes: a @reference path decomposed into steps, a sigil extracted from an argument value, a name: read off @table. Each is a function of captured rows. One column is not, and it is the macro inversion rather than a second exception: graphitron_field_synthesis.authored_type_sdl holds a pre-expansion type expression that appears in no graphql_ row, so a stratum-one fact currently sits inside a stratum-two family, and correcting the assignment is what removes it. That is the @asConnection half of the inversion, and it is what remains of it: the federation half is corrected, its rule now a derivation and its provenance relation retired. lint_ is stratum two as well, argued below.

  • Stratum three, queries. No family lands here, and the reason is the roster’s own naming rule rather than a gap. A family is named for whose vocabulary its rows are written in, which makes its rows facts the store owns, which is stratum two. A relation that exists because one consumer wanted one read has no vocabulary of its own to be named for, so it gets no prefix. That is why the two-versus-three boundary is not "a view a consumer reads": intent_resolved_field_claim is a view over captured facts whose own comment calls it what a planning reader eventually joins, and it is stratum two. The prefix-less diagnostic view is the worked case and the roster’s one placement exemption, a read surface unioning arms from several families' vocabularies. Cite it without an arm count; the store’s two statements of that count already disagree, which is an unguarded inventory rather than anything this section introduces. The exemption is over that relation’s name and population and says nothing about what a column on it may hold: coordinate passes the column rule below because its atoms ride the same row, which the view’s own comment argues, and a column truncating a path down to one of its segments failed the same rule on that same relation and was removed.

  • No stratum, scaffolding. walk_ and rejection_, each reifying the transitional walk’s answer so a derivation can be diffed against it while the migration runs. This bucket is decided before the strata question, and the ordering has to be stated because either test would otherwise pull both into stratum two: the rows of walk_ are recomputable by construction, that being what a differential is for, and rejection_ holds verdicts the rule below would reach. A relation that exists to be diffed against its own replacement has no stratum whatever its inputs are. That is what scaffolding means here, and the charter of rejection_ already ties its own lifetime to the clock walk_ keeps.

  • No stratum, permanently. store_ and meta_, whose subject is the store itself rather than any corpus or any fact about one. store_ records the run, what it read and what it was built from, and its charter already disclaims transcription in those words; meta_ records the schema. Both need saying, because "no stratum" otherwise reads as "transitional", which is what the pair above is and this pair is not. The meta_ views are also why stratum two has to be stated as a derivation over captured facts rather than as a derivation: meta_relation_family is a derivation over the schema’s own catalog rather than over anything a walk read, so it sits here. It is not stratum three either, meta_ being a family with a vocabulary of its own under the rule above.

A verdict is a conclusion, and the recompute test was stated over declarations, so verdicts need the test extended rather than applied as-is: a transcribed verdict is stratum one exactly while the store does not hold the inputs the verdict was computed from, and stratum two once it does. The rule reaches the verdict residents and does not land them together. graphql_syntax_error is stratum one, its input being a document that has not parsed, and there is no transcription of an unparseable file to recompute a syntax error from. graphql_schema_error splits along the stage column it already carries: a REGISTRY refusal names a second base declaration whose loser the registry reports as a verdict without offering it to capture, so the store does not hold the input, while every check the ASSEMBLY stage runs is a predicate over captured rows (that a named type resolves, that an object satisfies the interfaces it claims, that a directive sits where its definition permits, that the schema has a query root). So the REGISTRY arm is stratum one and the ASSEMBLY arm is stratum two. lint_ is stratum two, and this is the assignment that pays for the rule: LintEngine is one traversal over the parsed AST that graphql_ transcribes, so a lint finding is recomputable from captured facts, lint_finding is a table today, and the family’s own charter already names store-native predicates as the destination. build_warning_ is the one family the rule does not settle at family grain: the arm is defined by carrying no rule, so its residents share a channel rather than an input set, and whether a given advisory is a function of captured facts is a per-producer question. Stratum one until a producer’s inputs are shown to be captured, decided per resident. A disclosed gap at one of thirteen is the honest form; the failure mode is a clean-looking answer a reader cannot check.

Two of those results are inversions rather than descriptions, and naming them is not the same as acting on them: lint_ derives what it stores today, and the macro case stores a derivation where the captured fact belongs. Saying which assignment is right is what this page owes; moving the rows is a schema change.

That schema change has its first instance, so the sentence above is no longer only a promise. Federation’s key synthesis used to run inside the capture walk and land a synthesized @key in graphql_type_directive, its decode in graphitron_federation_key, and a provenance row in a relation whose only purpose was to say which of those rows a macro put there. The rule is now intent_synthesized_federation_key, a derivation whose own presence is its provenance, the provenance relation is gone, and stratum one at that coordinate is pure transcription of the SDL. What the instance demonstrates is the cost profile rather than the difficulty: the readers moved, the anchors moved, and no consumer of generated code noticed, the emitted schema’s synthesized @key having always come from the pipeline’s registry rewrite rather than from the store.

The corollary worth stating because it looks like a counter-argument: the foreign keys from graphitron_ into graphql_ are a derivation’s edges to its inputs. They are correct and permanent, and they argue neither for nor against separating the two families. Stated without a count, since an unguarded census rots silently; meta_relation_family and the roster gates are where the enumerable answer lives.

This section makes three claims of different enforceability, and one enforcer line covering all of them would overstate two.

Enforced by: for the claim that a stratum-two decode may not displace the stratum-one transcription, FactSchemaGateTest.theDecodeDoesNotReplaceTheTranscription and the verbatim-transcription twins already cited above, and the gap is worth stating precisely: the gate covers a decode that adds rows beside the transcription, and does not cover a macro that rewrites graphql_field.type_sdl in place, which is exactly the inverted case the macro paragraph below names.

Not mechanically enforced: the recompute test itself, except for its cross-corpus subclass, and the family assignment. One proper subclass of the recompute test is now gated, CaptureCorpusIsolationTest failing on a capture-time rule that reads a second corpus. Everything else the test would reject stays undetected: nothing in the suite fails when a captured relation turns out to be recomputable from facts inside its own corpus, which is why the two inversions named above sit in the store today with every gate green. The list is amended rather than shortened deliberately, since naming which part is covered is what lets a reader tell the two cases apart instead of reading the gate as closing the rule. What would close the rest is making each family’s stratum queryable data on the roster rather than charter prose, the way the roster already closes the family list; that is a schema change and a gate, not a page.

A claim whose enforcer does not close it still belongs here. The preamble’s bar is that a claim names its enforcer, and a disclosed gap satisfies that where silence would not, so a later reader should not read the two paragraphs above as an oversight. This is the one place the page argues with its own preamble rather than obeying it, the preamble asking for the gate "that fails when the claim breaks"; making the disagreement visible is better than quietly satisfying the letter. And the registration arms of FactCaptureAgreementTest are not the stratum’s reflection: they file graphitron_ under the containment arm beside graphql_, because the arms answer how a relation’s contents are pinned, not what its rows are computed from.

Derived reads are views, not stored facts

Most of what a consumer reads is derived, and each recurring derivation is easy to mistake for a stored thing and model wrong. The candidate space (completion, enumeration) is a base relation read without its key constraint, the same relation a resolve reads with one; storing a candidates list would duplicate the relation it selects from. Reverse indexes (find-usages, what-is-at-this-cursor) are inverted functional dependencies maintained for read speed; that a reverse lookup can find an entity does not make the lookup’s input the entity’s key. Stable ids are rendered keys, per the key discipline above.

The converse test matters as much: two spellings of one value are two base columns when neither is a function of the other, and the way to settle that is to look for the case where the derivation fails rather than the cases where it works. A method’s return type is carried both erased, as the JVM descriptor spells it, and as the source declared it. Most of the time the declared form determines the erasure, which makes the erasure look like a view over it, and one example refutes that: a type variable declares T and erases to its bound, so the declared form does not name what the erasure is. The other direction fails just as plainly, List not naming the element type List<Film> declares. Both columns are base, and the DDL comment owns that argument at each of them, because the next reader will otherwise reasonably try to delete one.

Which form a reader takes is then a property of the question, not a preference. A surface spelling a signature for an author wants the declared form, because the author is reading their own source back. A check on whether a method returns a particular type wants the erasure, because every Field<X> answers that question the same way while the declared spellings all differ. Storing one and computing the other would have forced one of those two to be wrong.

What a single column may hold has a rule of the same kind, and it is two clauses rather than a distaste for renders. A column may carry an opaque value the store did not compose, a captured message or a transcribed docstring, provided nothing joins, groups or filters on it; the DDL grants that permission in the same words at each column that has it, "display material, never a dimension". A column anything does join, group or filter on must be atomic to the engine and a function of its relation’s own key, and what fails that clause is a collection inside a scalar, with or without a delimiter to make it obvious. intent_type_backing_conflict holds both verdicts under one key: candidates, a count over the classes contesting a type, passes, and the same set joined into one string would fail, its element grain sitting inside the value where no key, constraint or join reaches it and only string surgery gets it back. What the rows answer and the joined string cannot is membership. A serialized set answers equality of the whole set and nothing else, where the contesting classes as rows on intent_type_backing under that same key answer that and membership too, for one join, which is what makes a set used as a canonical group key the case that looks legitimate and is not. That difference was paid once rather than being hypothetical: the claim-conflict relation used to carry the claiming directives of a conflict as one serialized value, the MCP diagnostics surface offered it as a filterable dimension, and asking for the conflicts involving one directive answered with only the conflicts whose entire set was that directive; the surface joins and asks membership now. Order is the neighbouring case and splits the same way, admissible as data (a captured ordinal, a position column) and inadmissible as a rule copied from a consumer’s vocabulary with nothing binding the copy, which is the argument the provenance section already makes about a naming order that is no captured fact of any graph and so no view’s to express.

Enforced by: CollectionValuedColumnGateTest in graphitron-model, for the named aggregate constructs in the DDL’s statement regions, reading the authored text rather than a booted catalog because a booted store has already lost the spelling. The gap is disclosed in that gate’s own javadoc rather than left to be found: a row-local scalar expression that discards part of a value trips nothing, detecting serialization inside an arbitrary expression not being mechanizable, so this paragraph is the coverage for that residue. It is closable case by case, each such expression being findable in review, and its exemplar is the path-truncation column the diagnostics surface used to carry.

A derivation gets a relation as soon as a second reader asks it. Written inside the first reader’s query it is a CTE, which is fine while it has one reader and wrong the moment it has two: the alternative to naming it is the second reader re-spelling it, and two spellings of one resolution agree exactly until one of them changes. intent_bound_table is the shipped case. Which catalog table a type’s @table binds to began as a CTE inside intent_column_match_claim, the classifier that asks it on the way to a claim, and became a view of its own when the language server started asking the same question with no claim in view. Naming it also made its declined case answerable: the classifier wants exactly one candidate and refuses an ambiguous binding, an editor wants to offer every candidate, and both readings come off the same rows because the arity is a column rather than a rule inside whoever counted first.

Resolutions layer among themselves, and the layering axis is what a resolution is keyed on. A binding is keyed on a coordinate, a type or a field, because that is what a reader holds when it asks. Underneath sits the rule that does not vary by coordinate at all: intent_spelled_table answers how a written table name meets the catalog census, and a qualifier bound against the schema, an unqualified name matching case-insensitively and a membership scope over the graph’s catalog sources are the same rule whether the name was written in @table(name:), in a @reference path element, or as a `@mutation’s delete target. The qualifier arrives already split: capture partitions a written reference on its first period and stores both halves beside it, because a grammar is a parse boundary SQL cannot express, so the view reads a partition rather than performing one. So the binding view became a keying over the spelling view rather than a second copy of it, and the population of the lower view is every spelling the graph authors, which is the honest reading of a relation keyed on a string: not every name the census holds, and not the subset one site happens to write.

A case fold is a stored column, and it is minted only where an authored spelling meets a catalog name. That crossing is the whole reason one exists: an author types a GraphQL or SDL identifier, the catalog holds a SQL one, and the two namespaces disagree about case, so each side of the comparison carries an _upper companion the database computes and nobody writes. Two consequences follow and both are load-bearing. A comparison between two values of one family mints nothing, because there is no boundary to bridge; where such a comparison does want folded operands it joins the relation that owns the spelling on its key and reads the fold there, which intent_name_matched_key_pair and intent_field_reference_discovery each do. And a derived view never forwards a fold to its readers, for the same reason it does not forward anything else it merely carried: the fold is a property of the relation that owns the spelling, and a reader wanting it re-joins rather than having a column threaded through every view between. What that buys is one rule with no list attached, and views that hold no per-row case fold on any comparison the rule reaches. What survives it is the one comparison the rule declines to serve: the defect view over node-identity metadata matches a generated table class’s stated key-column name against the catalog’s own column names, and both of those are values the crawler produced, so the fold there is a hedge rather than a semantic. That is not a fold this rule owes a column to; it is a fold nothing owes anything to, and it goes away by becoming exact rather than by being stored.

The same discipline splits a derivation when only part of it needs recursion. A @reference path resolves sequentially, an element departing from where the previous one arrived, so the chain is a recursive walk; but each element’s own resolution has no recursion in it. Those are two views. intent_field_reference_step_hop enumerates every table-to-table hop an element could express, both orientations of its foreign key included, because which direction the element means depends on where the chain stands; intent_field_reference_step_target walks them from the enclosing type’s binding. Keeping them apart is what lets the recursive term be a single join instead of a copy of every element arm, and it puts each arm’s rule in one place. Splitting the chain also forced two arities apart that a single count would have conflated: a path element naming a table three foreign keys connect reaches one destination by three routes, so targets and candidates are separate columns, and a reader needing only the table can trust an answer a reader rendering the join cannot.

A recursive view has to terminate on the population the store can hold, not on the one the subject would have. The worked example is a view that no longer exists, retired for what measuring it showed: an all-pairs assignability closure over jvm_class_supertype, taken as a view because a Java class hierarchy is acyclic where the SDL type graph intent_type_domain closes over is not, and materializing is what a closure over a cyclic relation costs. Acyclicity of the subject is not what makes such a recursion safe. The relation the closure ran over is the whole classpath census, which spans every entry every graph ever read, so one class name declared by two entries is routine and two such names declared into each other are a cycle both classfiles can be valid under. Three measured facts came out of that, and each is a rule for the next recursive view rather than a fact about the retired one.

A termination guard is unavoidable, because H2’s recursive UNION does not deduplicate against rows earlier iterations produced. A cycle under it is not a wrong answer but a hang with no diagnostic. The schema’s two reference-chain closures escape the guard a different way, on a strictly increasing position that is a column of the data rather than a predicate over the recursion’s own history.

A path guard is not free, and this is the claim the retired view got backwards. Guarding on the path terminates and then enumerates simple paths, which over 8,821 declared edges across 153 classpath entries costs seventeen seconds. That census declares no class name twice, so nothing about the seventeen seconds is a cycle or a duplicate; it is what an all-pairs closure over a census-scale relation costs. A closure that has to be cheap is anchored at the names its consumer asks about, which over the same census answers in a second, and it grows with what the consumer asks rather than with the census.

And the hazard the guard was reached for is duplicate rows more than cycles. A recursive term that joins on one column and projects another turns two rows differing only in the projected column into identical output rows; UNION ALL keeps both, both recurse, and the frontier doubles per hop. Forty stated rows reproduce the hang, so this needs no census-scale fixture to show. The rule that follows is to recurse over the pairs the edges denote rather than over the rows that declare them, which is what intent_authored_field_claim’s lookup-bearing closure does by recursing over an `input_object_field_edge CTE instead of over graphql_field directly.

The general form: the argument for a recursive view is about the rows the relation can contain, and a subject-level invariant ("a class hierarchy is a DAG") is only the same argument when the relation holds exactly one subject.

A derived view carrying a window function or a recursive term cannot be pruned by a predicate applied outside it, so a reader takes it once per answer and pairs it on its key rather than correlating it per row. The measurement that settles it, from the MCP schema read: intent_column_match_claim and intent_resolved_field_claim read as correlated MULTISET subqueries under a per-field projection cost twenty-four seconds over sixty types and eight hundred coordinates, where every other field-grain slot together cost a third of a second and those same two views read whole cost between two and seventy-five milliseconds. The column-match classifier collapses its matches with a ROW_NUMBER() OVER (PARTITION BY …​) over a derived relation, and a window sees its whole partition whatever the outer correlation says, so the correlated form pays the entire view’s evaluation once per driving row. Driving the statement from that view instead, filtered to the page, with the witnesses joined in as arity-preserving left joins, brought it under two seconds. A base relation correlated per row is the opposite case, an index seek that nests freely. So the rule is narrower than "avoid correlated subqueries" and sharper than "measure it": the view’s own shape decides, and a derivation this deep wants to be first in the FROM clause. Each view owes that warning in its comment, because the cost is invisible at the call site.

H2 inlines a view wherever it is named and eliminates no common subexpression, so a relation a derivation names four times is evaluated more than four times, and the multiplicities compound down a tree of views. Nothing at a call site shows it: a reader sees one SELECT against one name. The static form of the count is a reported metric, report-inline-multiplicity in the roadmap-tool run, which counts each relation’s textual references in each view body and multiplies them down the tree, needing no database and no profiler. What it ranks is breadth, and breadth is not cost: 2528 namings of a relation that answers in 0.4 s is not a problem, and 83 namings of one that answered in 20 s was. So the metric names suspects and a per-relation timing prices them, which is why it reports rather than gates.

A derived relation joined on an expression rather than on a column is evaluated once per driving row instead of once. Measured against the sakila example’s schema and catalog, a resolution rung shaped exactly that way over intent_resolved_type_binding cost 19.9 seconds for 157 rows, where projecting the expression as a column in an inner derived table first and joining the binding on that column cost 0.13 seconds for the same rows. Three controls on the same fixture isolate the expression as the term. Joining the field’s own named-type column directly, wrappers ignored, is 0.08 s, so the cost is not the row count. Routing the expression through graphql_type and joining the binding on its column, which is how two other sites in this schema spell it, is 19.6 s and no fix at all. And materialising the inner relation in a WITH clause first is still 19.6 s, because H2 inlines a non-recursive WITH exactly as it inlines a view.

That last control is the one to read carefully, because two rules stand next to each other here and read as contradicting each other when they do not. Extracting a relation for tidiness changes no join key, so the expression reappears in the key of whatever named the extraction and nothing is bought. Extraction that projects the expression as a column and joins on that column is the two-orders-of-magnitude fix. `intent_argument_scope_table’s own comment is the live exemplar of the second, calling its inner derived table load-bearing rather than a formatting choice, so it is the wrong citation for the first.

Every figure in this section is a timing, and one further engine behaviour decides when such a figure is real at all. H2 reuses the result of a repeated identical query, so a repeat is not a repeat until OPTIMIZE_REUSE_RESULTS is off. Measured on 2.4.240, six runs of one view query cost 98 ms and then 0, 0, 0, 0, 0, and the per-statement statistics H2 keeps when asked reported an average of 15.4 ms for a query that takes about 92. The under-report is proportional to how many repeats were asked for, which makes it worst exactly where an author is being careful and averaging several runs to damp noise, and it is silent, a plausible small number rather than a missing one. The setting is database-wide rather than per connection, so one statement covers every reader a session mints, and it has to be set the same way on both sides of any before-and-after comparison, because disabling reuse moves the absolute figures and not only the repeats.

A recursive term re-evaluates a relation named in its step once per accumulated row. The recursive UNION joins its own accumulated output against the step’s input, so a relation named there is evaluated as many times as the walk has rows rather than once, and inlining it makes the whole subtree under it the thing being re-evaluated. Measured on the same fixture: a step naming intent_node_id_decode_hop_column, which is 6.8 s evaluated on its own, produced 20 rows in 146 seconds, which is that cost times the rows the walk accumulates, where the same walk over those rows as a table is a little over 3 s for the whole reader. What was not the term is the step’s join predicate: collapsing its six-column coordinate key onto the use site, on the theory that four null-safe disjunctions were what stopped the step being planned, is a real simplification that moved the timeout not at all.

The general form tying those together, and the sentence to reach for before writing a rewrite: what makes a relation expensive is being a view that something reads many times, not how the reader spells the read. Two predicate rewrites over the node-identity family bought exactly nothing for that reason, and the two changes that did pay changed what a relation is rather than how it is named, one turning a join key from an expression into a column and one turning a recursive step’s input from a view into a table.

A reader meeting that rule reaches for CREATE MATERIALIZED VIEW, and on H2 it is unavailable rather than merely unattractive. H2 2.4.240 has the statement and real snapshot semantics, and on a synthetic stack of the shape above it takes a per-graph sweep from 5.9 seconds to 4 milliseconds, so the reach is well motivated. Four defects stand in the way, each a defect rather than a preference, and the first of them is also what makes this prohibition need no gate of its own. While a materialized view exists anywhere in the database, reading INFORMATION_SCHEMA.COLUMNS throws an internal NullPointerException, because the view is a shell object whose inherited column array is never populated and every query against its name is rewritten to a backing table before anything else notices; StoreCatalog reads that relation and jOOQ codegen boots off this schema’s live metadata, so one materialized view anywhere breaks the model build and the generated schema reference. A persistent database containing one cannot be reopened at all, because the stored DDL replays as CREATE FORCE MATERIALIZED VIEW and H2’s parser answers FORCE on that statement with an unimplemented-operation throw; the fact store is file-backed and persists warm across builds, so this one rules the feature out on its own. Dropping one leaves its backing table behind and reports a BASE TABLE row with a null name, so re-creating the same view then fails. And refresh is manual with no way to derive an order for it: refresh does not cascade, so an outer view stays stale until refreshed in its own right, and H2 exposes no dependency information anywhere, the three SQL-standard view-usage catalogs being absent and jOOQ’s own metadata carrying no dependency accessor. A refresh chain over layered views would therefore be hand-maintained ordering, which is the shape SchemaIdentifierDriftCheck exists to refuse: the universe of relations comes from the booted store and never from reading the DDL. The materialization registry’s refresh order does not contradict that objection, because the objection was to an ordering with no derivable source: the registry’s edges are parsed out of the booted store’s own stored view definitions by one routine into meta_materialize_dependency, an ordering single-sourced from the catalog rather than a hand-kept copy of one.

The ruling is conditional on that being the state of the tool rather than on the feature being wrong. Each of those defects was traced to its cause and the blocking ones were fixed and verified against H2 trunk, in twelve lines for the metadata read and about sixty for the persistence, at which point a file-backed store survives restart with its snapshot intact and correctly stale. None of that is in any release, none of it is filed upstream, and 2.4.240 is the latest, so there is no version to move to. Revisit the ruling when a release carries the fixes, and not before. Note also what materializing would and would not buy even then, because it is easy to over-read: batching a read pays one evaluation and materializing pays one evaluation plus the refresh, so for a write-then-read-once workload they are equivalent, and a snapshot only pays off where a relation is read many times between writes.

So where a derivation genuinely must be paid once and stored, the reduction is an ordinary table populated INSERT INTO derived SELECT …​ FROM <view>, which keeps the view as the single statement of the rule while making reads a plain indexed scan, and leaves a normal table for every other purpose: indexable by its own name, cleanly droppable, visible to codegen on our terms. Per-reader, the same shape is a LOCAL TEMPORARY table that disappears with its connection. Say LOCAL explicitly and treat a bare CREATE TEMPORARY TABLE as a trap: it defaults to GLOBAL, and H2’s global temporary tables share their rows across every attached session rather than only their definition, so in a store the language server, the MCP server and concurrent module builds all hold sessions on, one reader’s scratch snapshot would be every reader’s.

Which lever to reach for is itself ordered, and the order is a cost argument rather than a preference. A captured fact is the top rung, because it has no refresh to pay for at all: where the value is something a walk already read, writing it down at capture leaves every reader joining a column and no derivation either to evaluate or to refresh. A registration is the middle rung, and what it claims is exactly the distinction meta_materialize.reason draws, that the rule is right as a view and only too slow to evaluate per naming, where a hand-written derivation argues in its own table comment that no view could express its rule at all. A rewrite is the last rung, because it usually changes nothing the planner cares about, which is the general form above read from the other side. The trade the middle rung has to win is the one stated with the prohibition, a refresh against the re-evaluations it avoids, so a relation read once between writes gains nothing from a registration and a relation read many times gains the difference.

Having reached for that lever, the relation to register is the one every expensive reader has in common, low enough in the derivation tree that materializing it stops the re-evaluation for all of them, and not the relation that looked slow from where the reader happened to stand. intent_node_id_decode_endpoint is the measured case: three relations read it and each was paying for its whole subtree, so one 5.4 s refresh of it took the two that were timed from 7.5 s to 2.4 s each. The counter-case is the same test read the other way, a candidate whose one reader nothing exercises yet having every refresh buy nothing, which is what `meta_materialize’s hop-column registration says about itself and why it was made in the increment that adds the reader rather than when the cost was first seen. So the test is to count the candidate’s readers, price its refresh, and prefer the deepest relation whose materialization removes re-evaluation for more readers than the one you started from. Stopping short of that depth is measurably not a fix: with the endpoint materialized and the recursive step’s input left a view, the recursion reads a 2.4 s view once per accumulated row and is no better off than before.

What the depth rule leaves out is that a registration is a shared investment, so the reader it was bought for is not the only reader whose cost it changes. Materializing a relation replaces a rule with a table, and a table with no key on it is a heap: every join a derivation performs against that target scans all of it, where the same join against the rule reached base relations the planner could seek into. So the cost lands inside the derivations that read the target, once per driving row where the reader correlates and once per iteration where it recurses, and not on the reader’s own predicate. That distinction is the one to hold onto, because the reader’s predicate is where it looks like it should land and is not: a reader whose derivation carries a window function or a recursive term was never pruning the rule from outside in the first place, by the rule stated earlier on this page, and such a reader still gets an order of magnitude cheaper from an index on the target. Nothing about the motivating measurement would show any of this, the cost being invisible at the call site.

The lever is therefore underneath the reader, which is the ordinary direction, and no reader has to be restructured to reach it: declare an index on the target, on the columns a named reader joins it on, and give the planner current statistics after the refill that fills it. Measured on the read-cost gate’s own fixture, that took the deepest reader of the reference-step hop table from 18308 scans to 523, and it removed all three of the large regressions that stood in that gate’s pinned set. Half of the gain is the index and half is the statistics, and the second half has a placement constraint worth knowing before reaching for it: H2 commits the current transaction as a side effect of ANALYZE, so it cannot run inside the transaction a capture’s refresh runs in, and the capture path analyses after its transaction closes instead.

So the cost a registration is admitted on is not only what it saves its own reader but what it does to the others, and that is a build claim rather than a review habit: DerivedReadCostTest prices every pair of a registration and a relation reaching its target, in both shapes, and fails on a registration that costs another relation more scans than leaving it a view. The claim is directional and carries no number, so there is nothing in it for a later contributor to raise; a pair that is deliberately accepted is a row in the test’s own pinned set, asserted by equality so that the day the lever lands the row has to go. A large pair appearing there is a question about the target’s index before it is a question about the registration, which is what the three that left were: the registrations they were charged to are unchanged.

Two properties of that gate are worth knowing before reading its figures. Both of its axes come off the booted store, registrations from the register and readers from the same parsed-definition walk that derives the refresh order, so a view added to the schema puts its own cells in the domain rather than needing a list kept in step. And its fixture has to populate the relations it prices: a schema of @table-bound types carrying one scalar field leaves much of `meta_materialize’s roster and most of the readers empty, and over an empty relation the comparison measures only that H2 charges a table visit at least one scan per naming where a view whose evaluation short-circuits is charged none. That floor is why a small fixture makes this gate pass while seeing nothing, and why the answer to a gate that costs too much is fewer relations in its domain and never a smaller fixture.

Absence in a derived relation needs a stated meaning, and "not reached" is not "resolves to nothing". Where a chain stops because an element named an unknown key, the elements after it contribute no rows, which is the walk’s own behaviour; where an element carries neither a key nor a table its destination comes from a condition method’s return type, a resolution that view does not perform. Both are silences, and they mean different things, so the view’s comment says which silences it owns. A relation whose absence is load-bearing owes that sentence.

A relation may override rather than answer, and then its silence is most of it. intent_field_column_table says which table a column name written at a field’s site resolves against, but only where that table is not the one the field’s own parent is bound to. Stating the parent’s case too would make it a copy of intent_bound_table keyed one grain down, and every reader of it already holds the parent’s binding. So absence means "the reader’s own default stands", presence means "it does not", and a second disposition says "and nothing stands in its place", which is the case a default would get wrong rather than merely fail to help. An override relation is worth naming as such: its population is not the coordinates a question applies to, it is the coordinates whose answer differs from the default, and a test that pins where no row appears is pinning the boundary rather than reporting a gap.

A silence must not be sourced from a relation that is scheduled to drain. The same view could have read the rejection residue to learn that a coordinate already carries a report, which is a true fact and exactly the reason its incumbent stayed quiet. It does not, because a derivation resting on the residue goes quiet the day that family acquires its own derivation and leaves, and the change would look like nothing: no compile error, no failing pin, just a diagnostic appearing where one used to be suppressed. A derived relation’s meaning must not depend on where a message currently lives, so the silences are structural, and a silence that can only be stated transitionally is better left unstated until the fact it needs exists.

A derived relation is keyed by whatever its own question is about, and the stratum it lives in is decided by what its rows are a function of. intent_class_member_slot answers what member names a class offers an SDL author, a rule over the classpath census with no graph in it, so it carries the census’s key and a graph reaches it through store_graph_source like any other source-keyed fact. Keying it by graph would have stored one copy of the answer per graph that reads the class, which is a claim about the graph the rule never makes. It belongs in stratum two all the same, because its rows are a function of captured facts: the transcription families hold what a walk read, and a bean-accessor rule is not something any walk read, it is a computation over the classpath census a walk did. Being the family’s only resident that does not lead with graph_name is the shape of the question, not an exception to it.

A rule that a projection re-runs per build to hand the same answer to several readers is a relation waiting to be written. The bean rule had four readers in the language server and a fifth in the MCP schema resource, and it ran in the catalog builder to project a member list onto each type’s backing shape. Moving it into a view removed the projection’s payload entirely: a backing shape now names a class, and what the class offers is a read. The permits also stopped deciding it, which is what fixed a latent defect in the old shape: because the permit chose which projected list to consult, a record that also declared a bean accessor had two lists that could answer, and the reader took whichever the switch reached first. The relation chooses the arm by the class’s declared form, so there is one answer per name.

Where a macro rewrote a fact, read the written type expression rather than walking the expansion. A connection field’s own named type is the wrapper the @asConnection expansion synthesized, and the columns an author names on that field belong to the element type. The rule that needs the element could walk the expansion’s own fields, which is knowing the shape it expands into; instead it reads the type expression the field was written with and takes its named type. That is the stratum argument, and it is why the derivation is worth stating over the written form: a derivation over it works for any macro that rewrites a type expression, including ones that do not exist yet, while a derivation over the expansion’s shape is coupled to this one. Under the recompute test that also settles which of the two is the captured fact. The written expression is what the walk read, so it is stratum one; the expansion is a function of it, so it is stratum two. Today’s shape is the inversion of that: the expansion sits in graphql_field.type_sdl and the written expression is held as unparsed text in graphitron_field_synthesis.authored_type_sdl, which is why two views recover it with nested REPLACE calls stripping [, ] and !. That is the transitional present rather than the design, and correcting the assignment is what makes the side column unnecessary rather than merely misnamed. @asConnection is now the only expansion still running inside the walk, and it is the harder half to correct for the reason this paragraph gives: it rewrites a captured value rather than adding a row beside one, so there is no anti-join that recovers what the author wrote. It also has every right to run there, its inputs sitting entirely inside the SDL corpus, so no gate will force the correction.

Violations are located facts, not log lines. A violation is a row, not an act: the diagnostics stratum records rejection, lint, advisory, SDL-toolchain and compile rows (the rejection_, lint_, build_warning_ and javac_ families, plus the two verdict residents of graphql_), and the deliberately prefix-less diagnostic view unions them into one read surface with the rendered coordinate computed from the stored columns. Keeping the violation a relation makes every surface a projection of it (the LSP diagnostic, the MCP triage tools, a build report); baking it into one consumer’s output would force every other consumer to recompute the rule.

Gathering the schema is itself a staged pipeline, and a stage’s refusal never cancels the next stage. Five stages, each with its own unit:

Stage Unit Owns

1. Per-file parse

one file

source membership, graphql_syntax_error, and the per-site declaration facts, which carry the file’s own cadence and therefore survive any later stage’s refusal

2. Combined registry

the file set

the declarations admitted together, and graphql_schema_error at stage REGISTRY for what the registry refused

3. Assembly

the whole schema

the registry assembled into a GraphQLSchema, and graphql_schema_error at stage ASSEMBLY where it did not

4. Coordinate-keyed census

the file set

the merged effective element set: one row per coordinate, the losing occurrence of a duplicated one quarantined in graphql_duplicate_declaration

5. Rooted traversal

the assembled schema

intent_type_domain, the classification domain’s type members, from the seeds the document states

The first four judge, and the three that judge a document write their verdicts down. The parser judges one file at a time, then the registry judges the combined declarations, then assembly judges the registry against the GraphQL specification’s structural rules; graphql_syntax_error and graphql_schema_error hold all three refusals between them, and the later two share one relation because neither produces a declaration. Each stage keeps what survived it, so a source that will not parse costs its own declarations and no others, and a declaration the registry will not admit costs itself. That is what makes assembly worth running unconditionally, whether or not a pass has any use for the assembled schema: it is the only place the specification’s structural rules get checked at all, so its verdict is a fact about the consumer’s schema worth as much as the declarations it judges. Aborting at the first refusal instead is what lets one freshly broken file blank every fact about every file beside it, which is precisely when an author needs those facts most.

Assembly is the gatherer’s own stage, not something a caller hands it a verdict about. Stage 5 reads the schema stage 3 produced, so taking the transcription and the verdict from one assembly is what keeps the store from judging a document it does not hold. It also decides which registry gets assembled: the one the store transcribes, before the synthesis rewrites that inject declarations. Judging the post-synthesis registry instead let a verdict blame the author for a declaration graphitron’s own rewrite added.

Composed versus written is decided per relation, never per stage. The per-site declaration facts are stage 1’s and stay verbatim at their source’s cadence. What composition adds over them is either a view (an argument default is deliberately one join away, and filling it in at capture would store a derivation where a view answers) or its own coordinate-keyed relation whose comment owns why it is not a view. Stage 5 is the case that earned a relation: the closure over a cyclic type graph has no safe H2 view form, and the descent rule is graphql-java’s own child semantics rather than something SQL should restate edge kind by edge kind. Only what genuinely cannot exist without an assembled schema sits behind the assembly stage, which is why intent_type_domain is empty on a run whose registry did not assemble while every declaration fact beside it is current: absence there is read together with the ASSEMBLY verdict, never alone.

The reference web anchors on existence, never on attributes. Each SDL coordinate has a graphql_*coordinate relation carrying its key and nothing else, every foreign key in the schema that names an SDL coordinate names one of those, and the attribute relations beside them (graphql_type, graphql_field, graphql_argument, graphql_enum_value) are referenced by nothing at all. The reason is cadence and the failure it prevents is concrete. A coordinate’s existence is settled by the per-file parse, while an attribute may be owned by a later stage that can be withheld, rewritten or refused, so a foreign key across that boundary makes every per-site fact’s existence conditional on a stage it owes nothing to: one dangling type reference in a half-typed file would take the whole graphitron decode family with it, which is the failure above in its worst form. Before the split, graphql_type asserted both that a name exists and what its kind and description are, and the decode families and site rows had no choice but to anchor on the pair; a stage wanting the attribute half could not take it without taking the family’s availability too. Reading it: join the coordinate to ask what exists, join the attribute relation to ask what it is, and expect the two populations to agree wherever no later stage has taken an attribute over. One direction of that agreement is a foreign key (an attribute row with no anchor cannot be written); the other has no constraint that could see it, so FactCaptureAgreementTest pins both.

The four anchors and what hangs off each:

Element Anchor relation Attributes and dependents keyed on the anchor

type

graphql_type_coordinate

graphql_type (kind and description), graphql_type_declaration (every declaration site), graphql_union_member, graphql_implements, graphql_type_directive, the type-keyed graphitron_ decodes, intent_type_domain, intent_type_backing_class

field

graphql_field_coordinate

graphql_field (type expression, description, default, contributing site), graphql_field_directive, the field-keyed graphitron_ decodes, intent_input_occurrence_path_step

argument

graphql_argument_coordinate

graphql_argument, graphql_argument_directive, the argument-keyed graphitron_ decodes, intent_input_occurrence_path

enum value

graphql_enum_value_coordinate

graphql_enum_value, graphql_enum_value_directive, graphitron_enum_value_binding, graphitron_order, graphitron_index

The directive-definition family has no anchor of its own and needs none: a directive is defined once, nothing merges into a definition, and only its own two children reference it.

Enforced by: SdlCoordinateCensusTest pins capture’s merge against graphql-java’s own composition at all four grains, and every merge-ordered ordinal family by value rather than by density, on a fixture whose base definitions and extensions are deliberately out of order. The ordinal families are merge-ordered because capture numbers a type’s fields, arguments, enum values, union members and repeated directive applications with counters it holds per type and carries across the declaration sites, so a family checked only for density passes with the merge order inverted. FactCaptureAgreementTest pins each anchor against its attribute relation in both directions, the direction no foreign key can see.

Freshness is not a property any consumer carries. It used to be carried once, on the snapshot handle a consumer held, along two axes: whether a pass had produced a projection at all, and whether the one in hand was the latest parse or the last good one before a regression. The consumer split was purely which precondition each read imposed, and the editor’s was to tolerate the previous projection and tag what it showed, rather than punish an author for a half-typed edit. Two-stage capture removed the state that axis described. A file the parser refuses and a schema the assembler refuses are both rows, so there is no moment at which the store withholds an answer and nothing for a consumer to tag; the axis retired with its last reader. What is left is a lag of stated size rather than a state to switch on: the store answers for a file’s last saved content, one save behind the buffer, and a surface needing the unsaved text reads the buffer for it directly. The referenced namespaces (the jOOQ catalog, the classpath census) still lag their sources, and that lag is now visible as each family’s own capture cadence rather than as an axis on a handle.

Location is a fact about an entity, and the rule is cadence rather than storage. A position stored on a relation that refreshes more slowly than the position does freezes stale, which is why Java positions and Javadoc are not columns on jvm_class: the classfile census refreshes when a build produces classes, and a declaration moves when somebody saves a file. What follows from that is not that the position cannot be stored, but that it is stored on its own cadence. The java_ family holds one row per declaration a source parse read, keyed by the file it is written in and refreshed per file when that file changes, and a consumer joins it by name to the census, or to a captured generated-class FQN for the jOOQ half the census deliberately excludes. The relation is partial by design (a built-in has no position, and a headless session that walks no sources has none of these rows at all; a read allowed to be empty must not be a key), and the join is outer on both sides, because the two populations answer as of different moments and a view asserting they agree would assert something neither walk knows. SDL positions were always the same rule’s sanctioned side: the SDL is what capture parses, so its positions arrive in the same pass as the facts they locate and are stored as capture columns (source_name, source_line, source_column), in the declaration-site key even, because two extensions of one type can share a line. "Joined, not stored" is the law for positions that move on a cadence the fact does not; the principle underneath it is that a fact refreshes on the cadence of its own source.

Enforced by: DiagnosticFactsTest pins the diagnostics stratum’s derived columns against their Java spellings; DiagnosticsAggregateTest pins the union view’s shape through the shipped MCP tool; FactCaptureAgreementTest’s oracle-lifecycle gates pin that each post-capture writer clears exactly its own graph partition and nothing else; `JavaSourceFactsTest pins the source cadence’s own ownership scope, a file at a time, and CatalogRefreshTest pins that a .java edit moves the store row with no generator round. The materialized-view prohibition is enforced by the build itself rather than by a test: graphitron-model’s jOOQ codegen reads `INFORMATION_SCHEMA.COLUMNS off a live store booted from this schema, so a CREATE MATERIALIZED VIEW added anywhere in graphitron-model.sql fails the model build before any test runs. The LOCAL TEMPORARY requirement has no enforcer and is a trap to read rather than an invariant to lean on.

One base, many views

Every consumer (code generation, the language server, the MCP knowledge surface, the test corpus) reads views over the one base; no consumer owns a private model. Code generation is the narrowest view, not the model: it reads the resolved values and demands a total, integrity-clean snapshot, and it simply does not project the columns the other consumers live on (the authored form behind each resolved value, the description text, the source position). Those columns are in the base regardless.

The invariant that keeps it one model through the migration: every consumer re-sources onto the store, and each view’s coverage guarantee moves with its projection seam. A migration that leaves one consumer reading the old surface revives the leaves as a shim purely to feed that consumer, and the model forks; facts, revived leaves, and projections is three models. The strangler frame prevents it: consumers migrate one at a time, new facts land only in the store while both models are live, and the two-model window shrinks monotonically instead of fossilizing.

Enforced by: FactCaptureAgreementTest keeps the two live pictures honest for every relation while the window is open. No projection seam is left to gate: the classification projection was the language server’s view of the classifier, and it deleted with its last reader, so code generation classifies into its own model and reads that directly. Each consumer’s coverage gate is therefore over its own sealed vocabulary rather than over a switch two consumers share. GeneratorCoverageTest.everyGraphitronFieldLeafHasAKnownDispatchStatus partitions every model leaf by dispatch status, so a new variant is generated, declared unsupported, or breaks the build; TriggerDispatchMatrixTest pins the language server’s request triggers against its surfaces as an exhaustive partition, so a new trigger is answered, declared unanswered, or breaks the build.

A consumer’s answer is one projection at its own grain, never several grains folded back together in the consumer. jOOQ’s MULTISET nests a one-to-many child under its parent through the foreign key the child relation already declares, and Records.mapping lands each level on the record it already has; H2 serves the nesting by emulating it over JSON aggregation, two levels deep and with row(…​) inside a multiset. The alternative, several statements at several grains reassembled with accumulators and a synthetic grouping key, is a relational join written in Java, and it fails in three ways the nesting cannot. The grouping key has to be invented and can be invented wrong: a constraint name is unique per table and not per schema, so folding both foreign-key directions needs a four-part key that the relation states and the Java has to remember. Consistency has to be argued rather than held, since several statements need a read transaction wrapped round them where one statement is atomic. And the row count crossing the JDBC boundary is the product rather than the sum, a parent row repeating once per child. More than one statement is still right where the questions are genuinely independent, a page beside its unpaged total, or where two relations share neither a key nor a partition dimension and the store declines the correlation on purpose, as the classfile census and the source-declaration family do. What the rule forbids is one answer assembled from several grains after the fetch.

Picking the grain and picking the relation the statement drives from is one decision, taken before any SQL is written. Name what one row of the answer means, and the natural key of that sentence is the answer’s grain; the relation owning that key goes first in the FROM clause, and the rest attaches to it through keys the relations already declare. A child grain is never folded into that projection: it nests as a correlated MULTISET on the key its own relation declares, or it becomes a second statement paired on a real key, which is the shape SchemaQueries reads the MCP schema surface at, two statements at two grains paired on the type’s own key. Grain is stated prose rather than inference, so the register is the same one the store already keeps: a relation’s COMMENT ON says what one of its rows is, and an answer’s grain is a sentence of that kind about the projection. The smell is the reverse order, driving from whatever the caller happened to hold and repairing the shape afterwards with a grouping key the store never stated or a DISTINCT that hides a fan-out instead of answering it. Where the grain’s owner is a derivation carrying a window function or a recursive term rather than a base relation, the rule under "Derived reads are views, not stored facts" decides which way round the statement goes, and it is the view’s own shape that decides, not the reader’s spelling.

An empty relation is a fact about the population, and a reader that wants to distinguish "this name is wrong" from "nothing has told us about these names yet" must get both answers from one read. That distinction is real: a consumer who has not run their build yet has a census with nothing in it, and a surface that treats every name as unresolved then is wrong about every one of them. Splitting it into a presence check before a lookup makes correctness depend on the order a caller happens to write them in, and each caller writes them again. So the reader answers with the arms, not with a boolean: known, unknown, nothing captured. The shape follows what an arm carries; an arm set where none of them carries anything beyond its identity is an enum, and one where a single arm carries rows is a sealed interface. Where several arms of a surface share the deferral, it is decided once for all of them, at the dispatch, rather than restated per arm.

The back half: complete commands, a closed graph

The emit side consumes the facts as commands, and the law is that commands must be complete: the render shell makes no decision the planner could have made. A command row carries everything its renderer needs, and the command / plan / render package triangle keeps producers and consumers apart, with the emit library visible only to render.

The emit target is a referentially-closed graph of Java methods: a node relation (the methods the plan committed) and an edge relation (calls by name). Closure is bidirectional. Every method the generator emits is the render of exactly one committed command, and every callee name in every emitted body resolves to a method the run also emitted; a renderer minting a callee name the plan never committed is exactly the leak the invariant exists to catch.

Two rules govern the graph’s shape. The seam-placement rule: a named method call (a seam) belongs where a unit is chosen by a runtime dispatch, reused across more than one caller, or something the tests must assert independently; inline only a linear, single-use, non-varying construction. The single-mint naming regime: a callee name is minted once, in the plan’s naming vocabulary (GeneratedUnits), and read blind on both ends of the edge, never reconstructed by formula at a call site.

Enforced by: PackageImportDirectionTest for the triangle, and its unitRefsAreMintedOnlyByThePlansNamingVocabulary for the mint (unit references are constructed only inside GeneratedUnits, checked over the whole main tree); MethodClosureOracleTest for callee resolution over a seam-spanning schema; LauncherRelationClosureTest for the bidirectional half over the launcher relation (every covered coordinate has exactly one row, every row resolves to an emitted method, no two rows claim the same method).