Plan files are deleted on Done; this is the historical record. See Roadmap for in-flight work, or back to home.

Rewrite Changelog

Historical record of completed rewrite work. Entries are roughly reverse-chronological; commit shas and plan slugs are preserved for archaeology. The forward-looking ledger lives in README.md, generated from per-item front-matter in this directory.

The next-id: front-matter field is the canonical counter for R<n> allocation, maintained by roadmap-tool create. Numbers are never reused (see workflow.adoc); the counter advances past every Done so the gaps left by deleted item files don’t collide with future allocations.


  • R251 (1c1b425): AppliedDirectiveEmitter.buildApplication now skips arguments whose getArgumentValue().isNotSet() is true rather than feeding the NOT_SET slot into ValuesResolver.valueToLiteral (which assertShouldNeverHappen`s on it). The reconstructed `GraphQLAppliedDirective carries only explicitly-supplied arguments; consumer-side schema build resolves the rest from the directive definition’s declared defaults, matching graphql-java’s own applied-directive round-trip. Coverage: unit-tier AppliedDirectiveEmitterTest.omittedArguments_areSkipped_notRenderedAsNotSetLiteral builds @audit(reason: "pii") on a directive declaring both reason and ticket and asserts ticket does not appear in the emitted .argument(…​) chain. R248 fixed the adjacent directive-definition-side bug; R251 closes the application-side symmetry.

  • R248 (a2b1705): DirectiveDefinitionEmitter now round-trips argument default values (.defaultValueProgrammatic(…​) emitted via the same GraphQLValueEmitter.emit path ObjectTypeGenerator.buildArgument already uses for field arguments), so directive @key(resolvable: Boolean = true) survives the JavaPoet reconstruction with its default intact. The federation-namespace scalar fix is a sub-taxonomy lift on ScalarResolution: a new sealed Successful interface (javaType() accessor) sits between the root and Resolved, with a sibling Synthesised arm carrying (javaType, sdlName, coercingSourceOwner, coercingSourceField) for scalars that have no public static final GraphQLScalarType constant on the consumer classpath. ScalarTypeResolver.resolveFederationNamespaceScalar returns Synthesised(String.class, "federationFieldSet", _Any, "type") instead of the old Resolved(String, Scalars, "GraphQLString") placeholder; GraphitronType.ScalarType.resolution widens from Resolved to Successful; TypeBuilder’s federation-namespace branch and Java-type registry lookup narrow to `Successful; TypeBuilder.asRejection switches on Successful (every successful arm throws ; only rejections reach the dispatcher). AppliedDirectiveEmitter.emitInputType emits GraphQLTypeReference.typeRef(name) for federation-namespace scalars instead of Scalars.GraphQLString, so directive-definition and applied-directive argument slots both reference the synthesised scalar by name. GraphitronSchemaClassGenerator.build()’s scalar-registration loop dispatches on the variant: `Resolved emits the existing .additionalType(Owner.FIELD), Synthesised emits an inline .additionalType(GraphQLScalarType.newScalar().name(<sdl>).coercing(<owner>.<field>.getCoercing()).build()); _Any.type.getCoercing() is the same lever federation-jvm uses in ensureFederationV2DirectiveDefinitionsExist when synthesising missing federation scalars at the registry+wiring entry point. The misleading "federation-jvm transform() replaces the placeholder after the base schema is built" comments at ScalarTypeResolver.java:83-95 / :310-314, TypeBuilder.java:601-605 / :647-651, and AppliedDirectiveEmitter.java:122-134 retire ; Federation.transform(GraphQLSchema) only adds _Any / _Entity / _Service and wires entity resolution, never rewrites @key or injects scalars; the divergence reached the printed Service SDL untouched. Coverage: pipeline-tier FederationBuildSmokeTest.serviceSdlExposesCanonicalKeyDirectiveShape asserts the printed SDL carries directive @key(fields: federationFieldSet!, resolvable: Boolean = true) repeatable on OBJECT | INTERFACE and scalar federationFieldSet (the end-to-end behavior subgraph-composition tooling validates against); DirectiveDefinitionEmitterTest extends the existing argument-emit test with a .defaultValueProgrammatic( + "strict" assertion and adds a Boolean-default arm (@flag(enabled: Boolean = true)) to cover the GraphQLValueEmitter.emit dispatch on a different value shape; ScalarTypeResolverTest flips the federation-namespace resolver test to expect Synthesised(_Any, "type") and adds a second arm on linkImport to confirm the dispatch isn’t FieldSet-specific; GraphitronSchemaBuilderTest narrows existing ScalarType.resolution() reads to the Resolved arm they’re testing. Out of scope (called out): argument-level @deprecated on directive definitions (no survivor directive Graphitron emits today carries one); re-emitting @link itself (the federation library injects @link into the registry via LinkDirectiveProcessor, the survivor walker picks it up like any other directive); switching graphitron’s federation entry point from Federation.transform(GraphQLSchema) to the registry+wiring overload (would forfeit the prebuilt-programmatic-schema fast-boot model the rewrite chose in R10’s predecessor landing); federation v1 surface (FederationDirectives.key / _FieldSet.type / ensureFederationDirectiveDefinitionsExist) since FederationSpec.URL pins v2. Build green: full mvn -f graphitron-rewrite/pom.xml install -Plocal-db on Java 25.

  • R237 (96869ab + e24feac + ee9720f + 246b349 + aa83a16 + 8845717 + 18a37a2): Retire the @LoadBearingClassifierCheck / @DependsOnClassifierCheck annotation pair and the LoadBearingGuaranteeAuditTest audit infrastructure. 183 annotation blocks stripped across 50 Java files; the four annotation classes (LoadBearingClassifierCheck, LoadBearingClassifierChecks, DependsOnClassifierCheck, DependsOnClassifierChecks) and the audit test + auditfixture/ package deleted. Phase 2’s four-bucket classification of the ~59 active producer/consumer keys found |c-signal|=0: every cross-module producer-consumer pair was already mechanically pinned by graphitron-lsp tests (FieldCompletionsTest, HoversTest, DiagnosticsTest, DeclarationHoversTest, ValidatorDiagnosticsTest) or by structural type narrowing on the producer side, leaving no signal-bearing contract that needed a test-side replacement. Phase 3 picked Delete on that strength; two follow-up Backlog items track the structural type-system lifts that retire the residual producer-consumer linkages mechanically rather than via documentation: R239 (column-field-requires-table-backed-parent, b-cheap, single-record parentTable lift to ColumnField record component) and R240 (service-catalog-strict-tablemethod-return + tablemethod-resolver-return-is-table-bound, b-relational, type-token threading on MethodRef.StaticOnly × ReturnTypeRef.TableBoundReturnType). Knock-on: PkResolutionEmitterReachabilityTest.classifyDeleteTableProjectionWearsLoadBearingClassifierCheckPin retired with its annotation dependency (the sibling sealed-arm symmetry test still pins the rejection contract structurally); residual javadoc references rephrased across the rewrite tree in two waves: the initial Phase 5 sweep (aa83a16 + 8845717) covered 11 main-source files, 4 test files, and 18 roadmap-item plan bodies; a self-review follow-up sweep (18a37a2) caught a further 15 main-source, 6 test, 1 schema, and 1 docs site still carrying the retired load-bearing classifier check {key} framing ; each rephrased to anchor on the actual structural pin (sealed-variant arm, compact-constructor invariant, non-null record component, named resolver class) rather than the retired key. rewrite-design-principles.adoc Phase 1 rewrite shipped at 96869ab anchored the principle on the three surviving layers (type-system narrowing at producer, pipeline-tier tests, cross-module compile against graphitron-sakila-example); the principles-architect agent and srp / reviewer-prompt skill rubrics swapped the "Load-bearing classifier checks" rubric for "Missing type-system lift". Build green: full mvn -f graphitron-rewrite/pom.xml install -Plocal-db on Java 25.

  • R233 (01c0172 + d02859f): LSP @field(name:) completion + hover arms now resolve the column against the @reference path’s terminal table instead of the enclosing type’s @table, closing the bug R224 fixed for diagnostics but not for the two sibling LSP surfaces. FieldClassification gains a sealed nested LspColumnDispatch (permits Resolve(tableName) / Silent / FallThrough) and an exhaustive lspColumnDispatch() default method that switches over all 30 sealed permits with no default arm ; a new permit fails the switch to compile, forcing one deliberate placement before any consumer-side switch. The four column-bearing permits (Column / ColumnReference / CompositeColumn / CompositeColumnReference) produce Resolve(tableName) carrying R224’s already-projected terminal table; InputUnbound / Unclassified produce Silent; every other permit produces FallThrough. The three consumer sites (Diagnostics.validateFieldMember, FieldCompletions.completionsFor, Hovers.columnHover) collapse to a uniform 3-arm switch on the projection: Resolve and Silent return directly, FallThrough drops through to the existing backing-driven dispatch. FieldCompletions switches from TypeContext.enclosingFieldDefinition to enclosingFieldOrInputValueDefinition (R224’s helper) so input-side input_value_definition nodes resolve too; Hovers.columnHover adopts the same helper. A small mergeWithSigil helper in FieldCompletions shares the $source-sigil merge between the new dispatched arm and the existing backing arm. Annotation hygiene: field-classification-payload-faithful now has five consumer sites (InlayHints.compute, DeclarationHovers.compute, Diagnostics.validateFieldMember, FieldCompletions.completionsFor, Hovers.columnHover); the producer description at CatalogBuilder enumerates the five consumers and names lspColumnDispatch() as the routing primitive. Tests: pipeline-tier LspColumnDispatchProjectionTest drives the full classifier on a synthetic schema and pins the three arms (Resolve / Silent / FallThrough) plus a cross-permit invariant that ColumnReference.tableName() == Resolve.tableName(); FieldCompletionsTest gains three R233 regressions parallel to R224’s DiagnosticsTest cases (inputTableWithReferencePathCompletesTerminalTableColumns, outputTableWithReferencePathCompletesTerminalTableColumns, unresolvedReferencePathCompletionSilentOnLspSide); HoversTest gains three symmetric regressions (inputTableWithReferencePathHoversOnTerminalTableColumn, outputTableWithReferencePathHoversOnTerminalTableColumn, unresolvedReferencePathHoverSilentOnLspSide); R224’s three DiagnosticsTest regressions stay green untouched. Self-review cleanup (d02859f) inverted the spec’s double-Optional-of-Optional dispatch shape at the two new consumer sites to direct returns (Resolve and Silent each return directly, FallThrough drops through), preserving the exhaustiveness guarantee while removing the nested generic. Out of scope (called out, filed as R236): the runtime-side BuildContext.classifyInputFieldInternal candidate hint at BuildContext.java:1673 draws its "Did you mean" suggestions from the path-origin table rather than the terminal table ; different surface (compile-time validator message vs. interactive LSP) and audience, owned by a sibling Backlog item. Build green: full mvn -f graphitron-rewrite/pom.xml install -Plocal-db on Java 25.

  • R232 + R129 absorption (3079b99 + 001eee4 + 41e20a3 + 4bb9d4f + 70152d3 + 8effcbf): @reference(path: [{condition: {…}}]) paths now classify and emit a real correlated subquery / split-rows SELECT, replacing the build-time deferred-rejection that previously short-circuited the six condition-join-affected ChildField variants (TableField, LookupTableField, SplitTableField, SplitLookupTableField, RecordTableField, RecordLookupTableField) plus the seventh sibling ColumnReferenceField. JoinStep.ConditionJoin’s record header gains a `TableRef targetTable resolved at parse time by BuildContext.resolveConditionJoinTarget ; terminal hop from the carrier field’s return-type @table binding, intermediate hop by reflecting on the condition method’s second parameter type via JooqCatalog.findTableByClass ; with a compact-constructor null-check as the structural safety net behind the new condition-join.target-table-resolved-at-parse load-bearing-classifier key. The WithTarget capability splits into a smaller HasTargetTable (target read only) plus the slot-iteration WithTarget extends HasTargetTable, so ConditionJoin joins FkJoin / LiftedHop under one capability for the alias-declaration loop and JoinPathEmitter.targetJavaClassName collapse. A new sealed ParentCorrelation taxonomy (OnFkSlots / OnConditionJoin) lifts the step-0 fork between FK-slot correlation and ConditionJoin-method correlation out of every emitter site into the model, threaded through each affected ChildField variant’s record header with a parentCorrelation.firstStep() == joinPath.get(0) compact-constructor invariant. The inline emitters (InlineTableFieldEmitter, InlineLookupTableFieldEmitter, InlineColumnReferenceFieldEmitter) and the split-rows emitter (SplitRowsMethodEmitter’s `buildListMethod / buildSingleMethod / buildConnectionMethod) read the dispatch off the carrier; FK hops emit .join(alias).onKey(FK), condition hops emit .join(alias).on(method(prev, this)). For split-rows + OnConditionJoin, the prelude declares a fresh parentAlias Table local for the @table-bound parent, emits the step-0 .join(parentAlias).on(condition(…​)) clause, and routes parentInput to JOIN on parent-PK columns. Validator-side: validateVariantIsImplemented’s `SplitRowsMethodEmitter.unsupportedReason consult and validateColumnReferenceField’s `hasConditionJoin branch both delete; validateReferenceLeadsToType folds its WithTarget special-case onto a uniform HasTargetTable read. The ConditionJoinReportable capability interface deletes outright; Rejection.EmitBlockReason enum (six values, all condition-join-step) and Rejection.StubKey.EmitBlock record retire with their last producers; JoinPathEmitter.hasConditionJoin predicate retires with its last consumer; docs/manual/reference/diagnostics-glossary.adoc’s six `=== <variant>-condition-join-step headings delete per DiagnosticsDocCoverageTest. R129 absorption: the column-reference-on-scalar-field-condition-join slug closes ; ColumnReferenceField with a multi-hop path containing a condition step now classifies and emits via InlineColumnReferenceFieldEmitter; a single-hop condition-only path on a scalar return type AUTHOR_ERRORs at the parser with actionable rewrite guidance (use {table:} or {key:}), which is the same diagnostic shape the deferred-rejection used to surface, now produced one stage earlier. Tests. Pipeline-tier ValidationTest cases flip from deferred-rejection to no-error; new HasTargetTableInvariantTest pins the JoinStep-permits-implement-HasTargetTable invariant; ParentCorrelationFirstHopInvariantTest exercises both OnFkSlots and OnConditionJoin arms end-to-end; GraphitronSchemaBuilderTest gains CONDITION_ONLY_TERMINAL_RESOLVES_TARGET_FROM_RETURN_TYPE, TABLE_WITH_CONDITION_PRESERVES_WHERE_FILTER, KEY_WITH_CONDITION_PRESERVES_WHERE_FILTER (the last two are regression guards for the legacy {table:, condition:} / {key:, condition:} whereFilter-fold semantics), CONDITION_ONLY_NO_RETURN_TYPE_TABLE_REJECTED (AUTHOR_ERROR when the terminal-hop carrier’s return type has no @table), CONDITION_INTERMEDIATE_REFLECTS_METHOD_PARAM (reflection on the condition method’s second parameter type resolves the intermediate-hop targetTable), and CONDITION_INTERMEDIATE_TABLE_WILDCARD_REJECTED (AUTHOR_ERROR when the intermediate condition method uses Table<?>); plus extends the existing WITH_CONDITION_PATH fixture with a non-null targetTable() check. *Symmetric finish on RecordTableMethodField: the seventh @record-parent variant gains a ParentCorrelation field on its record header so a {condition:}-first path AUTHOR_ERRORs at parse time (same shape as the sibling RecordTableField / RecordLookupTableField variants ; @record-parents have no @table to anchor the condition method’s source arg, so the synthesis routes through AuthorError); the dead instanceof JoinStep.FkJoin arm in SplitRowsMethodEmitter.buildForRecordTableMethod’s `unsupportedPath predicate retires, leaving only the pre-existing R43 limits (empty + multi-hop). Compile-tier + execution-tier: Sakila gains two condition-method fixtures, Customer.addressByCondition: Address @reference(path: [{condition: …​}]) exercising the inline TableField emission shape and Film.actorsByCondition: [Actor!]! @splitQuery @reference(path: [{condition: …​}]) exercising the split-rows emission shape via an EXISTS-over-junction predicate; ReferencePathConditionFixtures ships the two condition methods. Compile-tier coverage flows through mvn install (generated code compiles against the real jOOQ catalog); execution-tier GraphQLQueryTest.inlineTableField_conditionJoin_returnsAddressPerCustomer and splitTableField_conditionJoin_returnsActorsPerFilm assert end-to-end SQL correctness against PostgreSQL (the inline test cross-checks against the FK-equivalent Customer.address navigation; the split-rows test verifies one batched DataLoader round-trip across five films). Out of scope, follow-up filed: the legacy ReferenceElement { table, key, condition } directive surface conflates join-shape with WHERE-filter and admits seven free combinations; the cleanup is filed as a separate Backlog item (path-element-surface-cleanup, R235).

  • R229 (ebfa633 + b9121e0): EnumTypeGenerator now honours @field(name:) on enum values by writing .name(<sdl>).value(<runtime>) into the generated <Name>Type.type() body, with the runtime string pre-resolved at classify time on a new no.sikt.graphitron.rewrite.model.EnumValueSpec (sdlName, runtimeValue, description, deprecationReason, source) carried as List<EnumValueSpec> values on GraphitronType.EnumType. Pre-R229 the emitter echoed .name(SDL).value(SDL) and the directive lookup was re-evaluated independently at the resolver site, so a federated subgraph returning the runtime form (e.g. "FØDSELSNUMMER" for FODSELSNUMMER @field(name: …​)) hit Can’t serialize value …​ Unknown value 'FØDSELSNUMMER' at graphql-java’s Coercing layer. With the directive lifted into the .value(…​) slot, graphql-java owns the wire ↔ runtime translation at the boundary in both directions, which collapses the Java-side CallSiteExtraction.TextMapLookup arm into Direct: the sealed permit, EnumMappingResolver.enrichArgExtractions, the TypeConditionsGenerator / TypeFetcherGenerator static _MAP emit paths, the ArgCallEmitter / FieldBuilder switch arms, and the EnumMappingResolver field/constructor params on ServiceDirectiveResolver / TableMethodDirectiveResolver all retire. EnumMappingResolver.buildTextEnumMapping / .validateEnumFilter read from the classified model’s List<EnumValueSpec> (lookup via ctx.types) so the directive is read once at classify time and both consumers share the record component, eliminating the drift R263 reintroduced. Coverage: pipeline-tier GraphitronSchemaBuilderTest.EnumTypeCase.ENUM_WITH_FIELD_NAME_DIRECTIVE pins the classifier output (runtimeValue == "FØDSELSNUMMER"); existing PLAIN_ENUM / ENUM_WITH_DEPRECATED_VALUE cases reroute through EnumValueSpec; new execution-tier EnumSerializationExecutionTest pins three boundary scenarios (directive value round-trips through Coercing on output; input round-trip delivers the runtime form to the resolver; simple-value identity fallback); unit-tier EnumTypeGeneratorTest.typeMethod_routesFieldNameDirectiveIntoRuntimeValue pins the generated .name(sdl).value(runtime) shape; the R53 regression SERVICE_MUTATION_FIELD_NAME_OVERRIDE_TEXT_ENUM flips its assertion from TextMapLookup to Direct (same scenario, conversion has moved to the wire boundary). *Out of scope (called out, filed as R231): graphitron currently lowers text-mapped-enum fields to GraphQL type String at emit time, so R229’s .value() lift is invisible to clients on Sakila’s textRating field; emitting those fields as the enum type is a separate structural fix. Self-review follow-up b9121e0 swept stale TextMapLookup / enrichArgExtractions javadoc citations across InputBeanResolver, ServiceCatalog, ServiceDirectiveResolver, TableMethodDirectiveResolver, ArgCallEmitter, BodyParam, ConditionFilter, InputColumnBinding, InputField, and MethodRef. Build green: full mvn -f graphitron-rewrite/pom.xml install -Plocal-db on Java 25.

  • R230 (8f59529): Fix BodyParam.nonNull for nested input fields under a nullable enclosing arg. walkInputFieldConditions now ANDs an effectiveNonNull boolean (seeded from the InputTypeArg’s `nonNull() at projectFilters, narrowed at each NestingField recursion) into the value passed to every implicitBodyParam / compositeImplicitBodyParam callsite, so the emitter’s unguarded condition.and(…​) branch only fires when every enclosing link is statically non-null. Pre-R230 a query like soknader(filter: HentSoknadInput): [Soknad!] with HentSoknadInput.soknadId: [ID!]! silently returned the empty set when filter was omitted, because the generator emitted condition.and(film.film_id.in(null)) and jOOQ renders .in(null) as the literal false. Producer contract pinned by a new body-param.nonnull-is-effective-runtime @LoadBearingClassifierCheck on walkInputFieldConditions (single annotation covers both producer sites in its description text ; audit requires producer-key uniqueness) paired with a @DependsOnClassifierCheck on TypeConditionsGenerator.buildConditionMethod. BodyParam.nonNull’s interface-level javadoc tightens to name the producer / emitter contract; the accessor’s one-liner reduces to a forward-pointer. Coverage: `NestedInputFieldEffectiveNonNullPipelineTest pins the three AND transitions on the classified slot (nullable arg → false, both non-null → true, nullable NestingField wrapper between non-null arg and non-null leaf → false); GraphQLQueryTest.filmsByEffectiveNullability_omittedFilter_returnsUnfilteredBaseline is the only tier that observes jOOQ’s .in(null) rendering, asserting the omitted-filter case returns the unfiltered baseline of 5 films rather than the empty set.

  • R223 (9c25edc + c8813a4): roadmap-tool gains a check-adoc-tables verify-phase subcommand that walks every authored .adoc under graphitron-rewrite/ and docs/, tracks five structural block types (|=== table, ---- listing, …​. literal, //// comment, passthrough), and fails the build on any markdown-separator row (|---|---|, with optional GFM alignment colons) found outside all blocks. Asciidoctor renders markdown table syntax as paragraph text with literal pipes, so the typo was invisible until publish; the truth table at graphitron-rewrite/docs/argument-resolution.adoc § "Truth table (per input-field, per call site)" was carrying this shape and is converted to AsciiDoc [cols=…​] + |=== syntax in the same commit. target/, node_modules/, and .git/ subtrees are skipped; .md files are out of scope (markdown table syntax is native there). AdocMarkdownTableCheckTest pins six fixtures: markdown separator outside any block is flagged; the same characters inside each of the five block types are not flagged; target/ directories and .md files are skipped by the walker; aligned (|:---|---:|) separators are flagged. CLAUDE.md "Writing style" gains a paragraph naming the rule and the new check. Out of scope (called out, owned by R227): mdBodyToAdoc does not translate markdown tables embedded in .md roadmap plans, so rendered roadmap .adoc under target/ still carries raw pipe rows; that render-side hole is tracked separately. Build green: full mvn -f graphitron-rewrite/pom.xml install -Plocal-db on Java 25; verify phase reports "no markdown-formatted tables in authored .adoc files".

  • R228 (de25b0a + ac5830d): GraphitronSchemaValidator now rejects inline TableField / LookupTableField whose @reference path carries a @condition step at build time, closing the gap that let those two variants reach a runtime UnsupportedOperationException while the four sibling variants surfaced a Rejection.Deferred build error. ChildField.TableField and ChildField.LookupTableField declare ConditionJoinReportable with their own EmitBlockReason values (TABLE_FIELD_CONDITION_JOIN_STEP, LOOKUP_TABLE_FIELD_CONDITION_JOIN_STEP) and displayLabel ("Inline TableField", "Inline LookupTableField"); SplitRowsMethodEmitter.unsupportedReason is the single predicate the validator and both inline emitters consult, so inline stubs render byte-for-byte the same message as before and the four four ChildField variants javadoc/comment sites widen to six. Tests: R58TypedRejectionPipelineTest gains inlineTableField_conditionJoinStep_rejectedAtBuildTime + inlineLookupTableField_conditionJoinStep_rejectedAtBuildTime pipeline-tier coverage; the existing seal-tracking assertion renames conditionJoinReportable_implementedByExpectedFourVariantsconditionJoinReportable_implementedByExpectedSixVariants; TableFieldValidationTest / LookupTableFieldValidationTest flip WITH_CONDITION_ONLY from "no error" to "stub surfaces as build error" and add LIST_WITH_CONDITION_ONLY. docs/manual/reference/diagnostics-glossary.adoc adds === table-field-condition-join-step and === lookup-table-field-condition-join-step paragraphs alongside the four existing entries (DiagnosticsDocCoverageTest gate). Sakila example: Category.similar was an inline-TableField ConditionJoin fixture deliberately admitted by the classifier and stubbed at runtime; the validator now rejects it at build time (intended outcome), so the field and its sole dependent CategoryConditions class are removed from the example along with the README.adoc reference. Out of scope (called out, owned by R3 item 5 + R129): lifting the condition-join restriction itself ; when item 5 ships all six variants' validator arms come out together with the two inline emitter stubs and the four runtime stubs in SplitRowsMethodEmitter. Note on test placement: spec named GraphitronSchemaBuilderTest but GraphitronSchema carries no rejections() accessor; fixtures landed in R58TypedRejectionPipelineTest (existing home for "build schema + run validator + assert typed rejection"). Build green: full mvn -f graphitron-rewrite/pom.xml install -Plocal-db on Java 25.

  • R224 (5b202fb + d1e8fd6): LSP Diagnostics.validateFieldMember consults the field classification before falling back to the type-backing table, so @field(name:) on a @reference(path:) field validates the column against the path’s terminal table instead of the enclosing type’s @table. For ColumnReference / CompositeColumnReference arms the lookup now resolves through FieldClassification.tableName() (projected via CatalogBuilder.terminalTableName), mirroring the runtime’s ServiceCatalog.resolveColumnForReference walk; Column / CompositeColumn arms route the same way (equivalent target table, sourced from the classification); InputUnbound / Unclassified arms stay silent because the validator already emits a precise message; other arms fall through to the existing backing-driven dispatch. The fix also adds TypeContext.enclosingFieldOrInputValueDefinition so the dispatch resolves the SDL field name on input-side input_value_definition nodes too (the prior enclosingFieldDefinition walked only output-side field_definition). The new emitter site wears @DependsOnClassifierCheck(key = "field-classification-payload-faithful") against the existing CatalogBuilder producer. Tests: DiagnosticsTest gains three regression cases driving synthetic LspSchemaSnapshot.Built.Current snapshots: input @table + @reference retargets to terminal-table column, output @table + @reference mirror, and silence-on-Unclassified (no duplicate "Unknown column …​ on table '<enclosing>'" diagnostic). Build green: full mvn -f graphitron-rewrite/pom.xml install -Plocal-db on Java 25.

  • R225 (9b753db): LSP Diagnostics.severityOf flips Rejection.Deferred from DiagnosticSeverity.Warning to Error, so the editor squiggle matches the mvn graphitron:dev finality (every Rejection variant throws ValidationFailedException, regardless of arm ; the actionable hint is the roadmap-item slug carried by the rejection, not the severity, reverting the R147 softening). ValidatorDiagnosticsTest.deferredMapsToWarningSeverity renamed to deferredMapsToErrorSeverity with its severity assertion flipped; RejectionSeverityCoverageTest unchanged (asserts only non-null). Build green: full graphitron-lsp test suite (347 tests) passes on Java 25.

  • R216 (2a15e15 + 70e41cb): LSP classification, hover, inferred-directive, completion, go-to-definition, and @field(name:) member-validation surfaces now walk extend type X { …​ } declarations in parallel with type X { …​ } definitions. A new closed-family DeclarationKind enum (graphitron-lsp/…​/parsing/DeclarationKind.java, 12 constants spanning both _type_definition and *_type_extension kinds) replaces the three out-of-sync Set<String> sources of truth in InlayHints, TypeContext, and DeclarationHovers; DeclarationKind.enclosing(Node) + DeclarationKind.walkAll(Node, Consumer) centralise the two walks every consumer used; isCarrier() filters the field-hover ancestor walk to coordinates where Parent.fieldName is meaningful. TypeContext.tableNameOf is rerouted through the classifier’s name-keyed projection on the snapshot (built.typeClassificationsByName().get(name)tableNameFromClassification) so an extend type Customer { …​ } whose @table-bearing definition lives in another file still resolves to the authoritative table name; the private tableNameOf(TypeClassification) helper in InlayHints lifts to TypeContext.tableNameFromClassification so inlay / hover / completion / definition / diagnostic surfaces share one switch. Snapshot threading reaches ReferenceCompletions.generate and Definitions.compute (both gain @DependsOnClassifierCheck(key = "type-classification-payload-faithful") so the audit test pins their new dependence on the type-classification projection); GraphitronTextDocumentService wires workspace.snapshot() to both. Tests: InlayHintsTest gains classification, inferred-@field, and absent-@table parity on extend type Query + extend type Customer; DeclarationHoversTest gains type-name and field-name hover parity inside extend type Customer; DiagnosticsTest gains @field(name:) member validation inside extend type Foo (unknown column + valid column); the existing ReferenceCompletionsTest.unknownTableReturnsEmptyForKey adapts to the snapshot-as-source-of-truth posture (classifier maps Foo to a missing table, completion empties). Self-review fix (70e41cb) dropped a defensive LinkedHashSet<Node> dedupe from walkAll ; tree-sitter ASTs are trees, the set guarded against an impossible scenario. *Out of scope (called out): generator-side admission of extend type Foo @table(name:"x") { …​ } (the classifier doesn’t see @table on extensions today, so the snapshot-routed tableNameOf stays silent on extension-declared @table without a corresponding definition ; lifting that constraint is a classifier-side change with its own roadmap item). Build green: full mvn -f graphitron-rewrite/pom.xml install -Plocal-db on Java 25; 1905 graphitron + 337 graphitron-lsp + sakila-example tests pass.

  • R217 (ee2802f + 72fd454): LSP inlay classification labels surface model leaf names, and a synthetic @table(name: "…​") ghost now renders on declarations whose classification is Table / Node / TableInterface / TableInput but that carry no @table directive at all. LspClassificationLabels.projectionLabel / projectionTypeLabel return each projection record’s simpleName() verbatim ("Column", "Table", "DmlMutation", …​) via exhaustive uniform-body switches that survive as compile-time tripwires for new permits; DeclarationHovers prints the qualified form (FieldClassification.Column / TypeClassification.Table) in hover headers; FieldClassification / TypeClassification class-level Javadoc records the new dual role of projection-record names. The generator-permit fieldLabel(GraphitronField) / typeLabel(GraphitronType) variants had no LSP callers and are deleted. InferredDirectiveArgs.Entry gains an AbsentArm absentArm slot (initial implementation used a boolean renderWhenAbsent flag, replaced in the self-review fix by a sealed AbsentArm strategy interface so a future entry that wants absent-rendering must implement or reuse a permit: flipping the field on without a matching renderer no-ops at compile time, not at runtime, preserving the canonical-arg table’s invariant "downstream consumers either pick it up automatically or fail to compile"). Today only the @table entry carries an arm (AbsentArm.TableName) whose switch encodes the eligibility set and reads tableName() off TypeClassification.{Table, Node, TableInterface, TableInput}; @field / @reference stay off per the spec’s judgement calls. InlayHints.collectAbsentDirectiveHints walks type-definition nodes in parallel with the classification arm, dispatching to entry.absentArm().resolveAbsentValue(c) via virtual call, emitting @<directive>(<arg>: "<resolved>") anchored at the type-name node when the type carries no directive of that name; the existing present-but-bare arm and the new absent arm share the canonical-arg table and the config.inferredDirectives() toggle. The @DependsOnClassifierCheck(key="type-classification-payload-faithful") reliesOn text widens to note the absent-directive arm. Tests: InlayHintsTest.classificationHintsLabelFieldDeclarations flips from "table type", "column" to "Table", "Column"; new absentTableHintRendersOnObjectTypeWithoutDirective, absentTableHintRendersOnInputTypeWithoutDirective, and absentTableHintSuppressedWhenDirectivePresent pin the new arm on object + input declarations and assert it stays quiet when the directive node is present; inferredTableHintSuppressedWhenAuthored extends to noneMatch(label → label.startsWith("@table")); DeclarationHoversTest flips header assertions to the qualified form. Build green: full mvn -f graphitron-rewrite/pom.xml install -Plocal-db on Java 25; 337 graphitron-lsp tests + 1897 catalog tests pass.

  • R215 (fdb757b + 3d60f40): column-binding requirement captured at classification, not derived at usage. BuildContext.classifyInputField gains a ClassifyContext parameter (carries expandingTypes + enclosingOverride); the recursive descent through NestingField composes ctx.expanding(typeName).withOverride(ctx.enclosingOverride() || nestOverride). InputField.ConditionOnlyField renamed to InputField.UnboundField(parentTypeName, name, location, typeName, nonNull, list, Optional<ArgConditionRef> condition, String attemptedColumnName); the classifier emits this variant uniformly on column-miss (across plain and @table inputs) and on @condition(override: true) with a matching column (the §5 ColumnField+override:true collapse). TypeBuilder.buildTableInputType defers column-coverage to consumption (admits UnboundField instead of rejecting the whole type as UnclassifiedType); FieldBuilder.walkInputFieldConditions becomes a single exhaustive switch with the UnboundField arm consuming enclosingOverride directly and emitting a consumer-side rejection (typed Rejection.AuthorError.UnknownName with Levenshtein hint) when the cascade doesn’t admit. GraphitronSchemaValidator walks TableInputType.inputFields() and rejects UnboundField + @condition(override: false) at the directive’s source location; MutationInputResolver.resolveInput admits UnboundField(condition: present, override: true) on UPDATE / DELETE and rejects on INSERT, plus rejects @condition(override: false) on any mutation input field at SDL-walk time. Eight downstream sealed-switch consumers updated (walkInputFieldConditions, MutationInputResolver, EnumMappingResolver, CatalogBuilder, ContextArgumentClassifier, GraphitronSchemaValidator, TypeFetcherGenerator.NOT_DISPATCHED_LEAVES, plus LSP LspClassificationLabels + DeclarationHovers and FieldClassification.InputUnbound renamed from InputCondition); InputFieldResolver.resolve(typeName, rt, enclosingOverride) takes the cascade flag from FieldBuilder.classifyArgument (fieldOverride || argCondition.map(c → c.override()).orElse(false)). Two new load-bearing classifier-check keys: input-field.unbound-implies-no-column (producer: classifyInputFieldInternal; consumer: walkInputFieldConditions) and input-field.unbound-with-override-condition-admits-on-mutation-update-delete (producer: resolveInput). Coverage: eight new R215 acceptance tests in GraphitronSchemaBuilderTest (r215_plainInputArgLevelOverrideAdmitsNonBindingField, r215_tableInputNonBindingFieldRejectsAtConsumer, r215_tableInputNonBindingFieldAdmittedUnderOverrideCascade, r215_validatorRejectsOverrideFalseOnNonBindingField, r215_validatorRejectsConditionOverrideFalseOnMutationInputField, r215_mutationUpdateConditionOverrideTrueOnNonPkFieldAdmits, r215_mutationInsertConditionOverrideTrueRejects, r215_nestedPlainInputPropagatesCascade); R210’s renamed plainInput_overrideTrueWithoutMatchingColumn_classifiesAsUnboundField + tableInput_overrideTrueWithoutMatchingColumn_classifiesAsUnboundField stay green; six existing tests asserting the pre-R215 rejection shape (EXPLICIT_TABLE_UNRESOLVED_COLUMN, NESTED_INPUT_FIELD_UNKNOWN_COLUMN, NodeIdPipelineTest.InputCase.{ACCESSOR_MISSING, LIST_VARIANT}) updated to assert the new admit-at-type-build behaviour. Late-round patch (after self-review with principles-architect, alf’s pushback on cascade-contract gloss): the first-pass walkInputFieldConditions UnboundField arm silently dropped the inner @condition under an outer @condition(override:true) cascade, contradicting docs/manual/how-to/migrating-from-legacy.adoc#behavior-divergence-condition-cascade ("every @condition you write produces SQL; the override flag controls only the implicit column predicate"). The arm now mirrors the ColumnField arm structure (always emit the explicit @condition when present; decide rejection separately): rejects at the consumer outside the cascade for condition.isEmpty() (no filter contribution) and condition.isPresent() && !override() (structurally malformed shape) ; the second arm acts as a safety net for plain inputs until R221 lifts validator coverage there. New acceptance test #11 r215_innerExplicitConditionFiresOnUnboundFieldUnderOverrideCascade pins the cascade-doc contract (two ConditionFilter`s emitted: outer arg-level + inner field-level); the three R205 Path B regression tests (`ArgumentParsingCase.PLAIN_INPUT_ARG_FIELD_CONDITION_EMITTED, plainInput_unresolvedFieldWithCondition_rejectsAsUnclassifiedFieldWithUnknownName, plainInput_overrideFalseWithoutMatchingColumn_stillRejectsAsUnclassifiedField) keep their pre-R215 rejection assertions green via the consumer-arm safety net. Out of scope (called out, deferred to follow-ups): MutationField.{Value, Condition} sealed projection from MutationInputResolver for downstream DML emitters (acceptance behaviour shipped at the resolver’s per-field admission loop; the structural lift to a sealed projection is a follow-up roadmap item ; no emitter consumes a MutationField projection yet); R213 exact-SourceLocation attribution on the surrounding UnclassifiedField’s `location field (the rejection prose names the field but the wrapper’s location still points at the consuming query field; threading the location through walkInputFieldConditionsprojectFiltersprojectForFilterTableFieldComponents.Rejected is the R213 follow-up); R221 validator walks PlainInputArg.fields() for the same UnboundField + @condition(override:false) rejection the shipped validator catches on TableInputType (the consumer-arm safety net above covers non-cascade plain inputs; the cascade case admits-and-emits today, which R221 will reject at the directive’s location). Build green: full mvn -f graphitron-rewrite/pom.xml install -Plocal-db on Java 25; 1906 graphitron tests + 334 graphitron-lsp tests + all sakila-example tests pass.

  • R211 (831a32d): @condition(override: true) build failure no longer surfaces the misleading "no column 'X' found in table 'Y'" line alongside the actionable condition error. BuildContext.classifyInputFieldInternal reinstates an errorsBefore size-delta check inside the R210 override:true block: when buildInputFieldCondition appends to errors and returns empty, the gate returns a placeholder InputFieldResolution.Unresolved(lookupColumn=null, "@condition(override: true) failed to build; see condition error") instead of falling through to the column-miss arm; the column is unused by construction under override:true. The override:false leg never enters this branch, so R205 acceptance test #6’s typed AuthorError.UnknownName lift survives (InputFieldResolver.resolve’s `canLiftToUnknownName guard sees condErrors non-empty + lookupColumn null and folds to Rejection.structural, which is the right bucket ; the failure shape is condition-method binding, not unknown-column). R210’s existing test plainInput_overrideTrueWithBrokenCondition_rejectsAsUnclassifiedField gains a doesNotContain("no column 'sakskode' found") assertion so a regression that reintroduces the column-miss arm under override:true trips at the existing test site. Surfaced by alf’s production opptak-subgraph (parameter-name mismatches in OpptakFilterInput.opptaksNavn / utdanningstilbud); the related attribution issue from the same investigation is filed as R213. Will be subsumed by R215’s column-binding-at-classification restructure (the override:true gate moves above the column lookup, making the column-miss arm structurally unreachable on this branch); the doesNotContain assertion stays as a regression guard. Build green: full mvn -f graphitron-rewrite/pom.xml install -Plocal-db on Java 25.

  • R203 (2b8b465 + 8d39ef4 + 9ac3e4c Phase 1 natives module + release workflow; first release no.sikt:graphitron-tree-sitter-natives:0.26.0-1 published to Maven Central via the tree-sitter-natives-release workflow on its four-platform matrix [Phase 2]; ae486d9 Phase 3 graphitron-lsp cutover; bbabb02 BundledLibraryLookup probe for well-known libtree-sitter install prefixes; a94f546 In Review → Ready rework after first review pass; a3338b6 Phase 4 distinguishes too-old libtree-sitter from missing and ships the spec-named error-translation unit test): graphitron-lsp no longer compiles a per-platform tree-sitter runtime + grammar on every mvn install. The vendored libtree-sitter runtime sources and the bkegley tree-sitter-graphql grammar parser.c are gone from graphitron-lsp/src/main/native/ (≈30 000 lines deleted); the three build-native-* Maven profiles and the exec-maven-plugin shell-out are gone from graphitron-lsp/pom.xml. The grammar binary now ships from a new same-repo standalone Maven module graphitron-rewrite/graphitron-tree-sitter-natives/ (groupId no.sikt, version stream <tree-sitter-runtime-ABI>-<build-n>, first release 0.26.0-1; standalone pom intentionally not a child of graphitron-rewrite-parent and not in the parent reactor’s <modules> list, so the release cadence decouples from the rewrite’s 10-SNAPSHOT parent and mvn install -f graphitron-rewrite/pom.xml -Plocal-db pays zero build cost). The natives release workflow is workflow_dispatch-only on a four-platform GitHub Actions matrix (linux-x86_64, linux-aarch64, macos-aarch64, windows-x86_64; macos-x86_64 dropped during Phase 1 dry-run since Sikt LSP developers all run M1+); each matrix runner runs upstream’s tree-sitter build CLI against the vendored grammar to produce one platform-shaped shared library, the jar carries exactly four lib/<os>-<arch>/tree-sitter-graphql.{so,dylib,dll} entries (POSIX lib prefix; Windows unprefixed per platform convention), and a post-deploy load+parse matrix verifies the published artifact resolves into a fresh local m2 and the bundled grammar loads against an OS-installed libtree-sitter on every platform. graphitron-lsp BundledLibraryLookup switches to the four-platform set, drops the previous UnsupportedOperationException Windows branch, and now also probes well-known libtree-sitter install prefixes (Homebrew /opt/homebrew/lib + /usr/local/lib on macOS, vcpkg’s <VCPKG_ROOT|VCPKG_INSTALLATION_ROOT>/installed/x64-windows/bin + the default C:\vcpkg... on Windows, /usr/local/lib on Linux), composing a system-installed runtime onto the SPI grammar lookup via SymbolLookup.or so vanilla brew install tree-sitter / vcpkg install tree-sitter:x64-windows work with no env-var wiring. GraphqlLanguage.loadOrExplain translates UnsatisfiedLinkError / RuntimeException from Language.load into an install-instructions message and now also distinguishes "too-old libtree-sitter installed" (commonly Debian/Ubuntu apt’s libtree-sitter0 0.20.x, which predates the ts_language_abi_version symbol jtreesitter 0.26 looks up) via a probe-path classifier that walks a BundledLibraryLookup-superset including apt’s /usr/lib/<arch>-linux-gnu/libtree-sitter.so.0 and checks the ABI symbol directly. GraphqlLanguageErrorTranslationTest pins the classifier (cause-chain walk, ABI-symbol failure shape, ignores unrelated errors), the missing- and too-old- runtime messages per OS via @EnabledOnOs, and explicitly names libtree-sitter0 in the too-old Linux hint. NativeLibraryBundleTest covers all four platforms via per-platform @EnabledOnOs methods (3 skipped on any single host); rewrite-build.yml source-builds libtree-sitter v0.26.9 so the linux-x86_64 method runs green in CI. getting-started.adoc gains a "Native runtime dependency" section with [#native-runtime-dependency] anchor and a per-platform install + library-discovery table (including a NixOS shell.nix snippet for nix-store layouts and the JAVA_TOOL_OPTIONS=-Djava.library.path=…​ escape hatch for non-default installs). Subsumes and deletes R89 (lsp-native-build-multiplatform-ci): the multi-platform-CI concern is now the post-deploy matrix on the natives release workflow plus the four @EnabledOnOs NativeLibraryBundleTest methods, not a per-PR matrix on rewrite-build.yml. 334 graphitron-lsp tests pass (7 skipped: 3 platform-gated NativeLibraryBundleTest + 4 platform-gated GraphqlLanguageErrorTranslationTest methods on the Linux CI host); full mvn -f graphitron-rewrite/pom.xml install -Plocal-db green on Java 25.

  • R210 (94bc3bf + 47f5f39 + ac2588c): @condition(override: true) on an input field with no matching column no longer rejects as Unresolved under R205’s Path B. New InputField.ConditionOnlyField sealed permit carries the ArgConditionRef with no column data; BuildContext.classifyInputFieldInternal gates on the directive’s override flag at the "no column found" fall-through (cheap read, no errors-list side effects) before building the condition, so the typed AuthorError.UnknownName lift on override:false reflection failures is preserved at the R205 boundary. FieldBuilder.walkInputFieldConditions emits the explicit ConditionFilter only; six other exhaustive InputField consumer sites (ContextArgumentClassifier, GraphitronSchemaValidator, EnumMappingResolver, CatalogBuilder + new FieldClassification.InputCondition record, TypeFetcherGenerator.NOT_DISPATCHED_LEAVES, LSP hover + inlay) grow explicit arms; MutationInputResolver’s existing default-arm rejects condition-only carriers as structurally unfit for DML. Symmetric across plain inputs (the reported opptak-subgraph `SakFilterV2Input.sakskode shape) and @table inputs since both share classifyInputFieldInternal. Coverage: three R210 acceptance tests (plainInput_overrideTrueWithoutMatchingColumn_classifiesAsConditionOnlyField with @ProjectionFor(ConditionOnlyField.class), tableInput_overrideTrueWithoutMatchingColumn_classifiesAsConditionOnlyField, plainInput_overrideTrueWithBrokenCondition_rejectsAsUnclassifiedField) plus a boundary test (plainInput_overrideFalseWithoutMatchingColumn_stillRejectsAsUnclassifiedField) pinning the R205↔R210 behaviour boundary by name; TestConditionStub gains sakskodeCondition / syntheticNameCondition fixtures; VariantCoverageTest.NO_CASE_REQUIRED carries the rationale for the @Test-not-enum-case shape. Out of scope (called out): execution-tier Sakila fixture mirroring the production shape ; deferred (pipeline tier already exercises classifyInputFieldInternal and the projected ConditionFilter). Design alternative considered: InputFieldResolution.ConditionOnly arm (resolution-tier sibling, structurally honest, deferred to a future refactor if the carrier-vs-resolution distinction becomes load-bearing); the carrier-tier InputField.ConditionOnlyField permit chosen for incremental change cost + uniform walking. Build green: full mvn -f graphitron-rewrite/pom.xml install -Plocal-db on Java 25; 1893 tests pass.

  • R205 (fdada52 restore plain-input filter symmetry + escalate Unresolved to build error, c1de3f8 self-review follow-ups + R209 backlog stub): plain-input (non-@table) filter input types now classify and project identically to @table inputs. InputFieldResolver.resolve returns sealed Resolution.{Ok, Rejected} (mirroring OrderByResolver.Resolved); any InputFieldResolution.Unresolved or @condition reflection failure lifts as a typed Rejection (single column-miss → Rejection.unknownColumn so LSP fix-its consume the structured attempt + candidates; everything else folds to Rejection.structural with joined prose). ArgumentRef.UnclassifiedArg’s `String reason becomes Rejection rejection with a backwards-compatible reason() accessor; the four other construction sites in FieldBuilder.classifyArgument wrap their prose with Rejection.structural(…​). FieldBuilder.projectFilters’ `PlainInputArg branch is now structurally identical to TableInputArg: it allocates a non-null implicitBodyParams and drains it into bodyParams, so the symmetric implicit-predicate emission is a type-system fact (the four per-call implicitBodyParams != null guards in walkInputFieldConditions collapse; the method asserts non-null at entry via requireNonNull). projectFilters / projectForFilter thread List<Rejection> end-to-end via a new foldRejections helper; UnclassifiedArg.rejection.prefixedWith(…​) preserves typed payloads (e.g. AuthorError.UnknownName from a plain-input column miss) through to UnclassifiedField.rejection. ProjectionCoverageTest drops the PojoInputType allowlist entry; a new @ProjectionFor(PojoInputType.class) projection test (plainInput_resolvedColumnWithoutCondition_emitsImplicitBodyParam) pins the implicit-predicate emission on the plain-input path. Six acceptance tests cover symmetric implicit emission, explicit+implicit composition, override propagation, rejection on Unresolved with/without @condition, and rejection on @condition reflection failure (Path B: bare-field-without-@condition signals binding intent just as much as @condition-annotated does). The languagesByPlainInput sakila-example fixture + execution test, which encoded the silent-drop as expected behaviour, are deleted; docs/argument-resolution.adoc retires the per-field-skip rationale paragraph at :400-412 and adds an R205 anchor sentence to the truth table at :262-275. Out of scope (called out): project-wide design-doc-vs-implementation conformance audit (filed as R207); auto-binding via @condition method when no column resolves; FieldRegistry classify-input trace’s typed-Rejection payload loss (filed as R209). Build green: full mvn -f graphitron-rewrite/pom.xml install -Plocal-db on Java 25.

  • R206 (3a44d47): synthesised ConnectionType / EdgeType carry the @asConnection carrier field’s SourceLocation. ConnectionPromoter.promote now passes BuildContext.locationOf(fieldDef) into the two record constructors instead of null; first-write-wins on dedupe (the existing instanceof ConnectionType early-continue preserves the first carrier’s location) and PageInfoType.location() deliberately stays null because a single PageInfo serves every connection so no carrier site is the actionable one. Downstream, GraphitronSchemaBuilder.rejectCaseInsensitiveTypeCollisions already reads existing.location() when demoting to UnclassifiedType, so SYNTH_VS_SYNTH / SDL_VS_SYNTH / SYNTH_EDGE_VS_SDL ValidationError`s now carry an actionable position an LSP/editor can jump to. `CaseInsensitiveTypeClashCase migrated Consumer<GraphitronSchema>BiConsumer<GraphitronSchema, String> so each arm sees its own SDL fixture; SYNTH_VS_SYNTH pins both line and column (via new TestSchemaHelper.preludeLineCount); SDL_VS_SYNTH and SYNTH_EDGE_VS_SDL pin the synth side’s carrier column; SYNTH_PAGE_INFO_VS_SDL explicitly asserts the synth member’s null location locking in the design choice. No record-shape changes; pure provenance threading.

  • R204 (930739a): validate uniform env.getSource() domain return type across OutputField producers on an SDL type. Lifts OutputField as a sealed sub-interface of GraphitronField (permits RootField, ChildField) declaring DomainReturnType domainReturnType(); new sealed DomainReturnType (Record(TableRef) | TableRecord(ClassName) | Plain(ClassName)) mirrors the producer’s env.getSource() Java domain identity without classloading at validator time. Post-classification GraphitronSchemaBuilder.validateUniformDomainReturnType groups OutputField entries by SDL Object return-type name and demotes every participant in a multi-arm group to UnclassifiedField with a typed Rejection.AuthorError.MultiProducerDomainTypeDisagreement; the validator carries @LoadBearingClassifierCheck(key = "output-fields.uniform-domain-return-type") paired with a matching @DependsOnClassifierCheck on FetcherEmitter.buildSingleRecordTableFetcherValue. The two formerly-@Disabled mixed-producer cases in SingleRecordTableFieldServiceProducerPipelineTest now assert against the unified-path diagnostic; unit-tier DomainReturnTypeCoverageTest walks the sealed-permit graph by reflection and pins per-arm structural equality. Per-permit narrowing (design fork from the spec draft’s broad-detection wording): table-bound service producers answer Record(table) rather than TableRecord(recordClass) because typed XRecord IS-A jOOQ Record and children read by name through the generic Record interface; only the carrier-payload case (DML @mutation Record(table) vs @service-on-Mutation TableRecord(XRecord) for the same payload SDL Object) surfaces today. Build green: full mvn -f graphitron-rewrite/pom.xml install -Plocal-db on Java 25.

  • R190 (182ec24 sealed GraphitronContext + schema-driven Graphitron.newExecutionInput(DSLContext, …) factory: ContextArgumentClassifier walks every MethodRef.Param.Typed whose source is ParamSource.Context, rejects mutually-incompatible Java types per contextArgument name as a typed Rejection.AuthorError.TypeConflict, and stores one ResolvedContextArg(name, javaType, sites) per name on a new Classification carrier; GraphitronContextInterfaceGenerator now emits public sealed interface GraphitronContext with a nested public static final class GraphitronContextImpl implements GraphitronContext (same-compilation-unit permits, no javapoet permits surface needed) carrying a public INSTANCE field and a private constructor, getTenantId removed, getDslContext demoted to a default reading env.getGraphQlContext().get(DSLContext.class); GraphitronFacadeGenerator.newExecutionInput collapsed to a single overload with DSLContext defaultDsl first then one parameter per ResolvedContextArg in alphabetical order, body null-checks every slot and populates GraphQLContext with DSLContext.class, each contextArgument string key, and the singleton GraphitronContextImpl.INSTANCE under GraphitronContext.class; five DataLoader name emission sites de-prefixed (DataLoaderFetcherEmitter, TypeFetcherGenerator ×2, MultiTablePolymorphicEmitter ×2, QueryNodeFetcherClassGenerator); HandleMethodBody federation entity dispatch grouping collapses Map<Integer, Map<String, List<Object[]>>> to Map<Integer, List<Object[]>>; graphitron-sakila-example migrated end-to-end (deleted AppContext.java, GraphqlResource calls Graphitron.newExecutionInput(dsl, "test-user"), 14 anon-impl test sites collapsed, two getTenantId-override tests commented out with forward-reference to R45); 8d99486 14-page user-doc rewrite (getting-started.adoc, runtime-extension-points.adoc, runtime-api.adoc, test-your-schema.adoc substantive rewrites; tenant-scoping.adoc, apollo-federation.adoc, split-vs-inline.adoc deferral banners pointing at R45; index + in-prose touch-ups across how-it-works.adoc, batching-model.adoc, 06-going-further.adoc, add-custom-conditions.adoc, security.adoc, graphitron-rewrite/docs/README.adoc); b408253 L2 ContextArgumentTypeAgreementTest (accepted + three-site conflict fixtures) + L4 ContextArgumentTypeAgreementValidationTest (pins the validator-mirrors-classifier drain renders header + indented per-site lines and exposes the typed sites field); f1a6b7a L4 GraphitronFacadeGeneratorPipelineTest (classified two-@service(contextArguments)-site SDL; asserts alphabetical parameter ordering, per-slot requireNonNull, the graphQLContext lambda body’s typed/string puts, the DataLoaderRegistry attach), L5 example SDL gains the single @service(contextArguments: ["userId"]) site (Query.greetingByUserUserGreetingService.greet), L6 FilmContextArgumentRoundTripTest (round-trip threading through to the service method; singleton-throws-on-missing-with-factory-hint diagnostic; hand-rolled-ExecutionInput.Builder-redacts-through-framework end-to-end); 0aa1ee7 self-review pass addressing principles-architect findings ; Classification cached on GraphitronSchema as a 6th component populated once at parse boundary (validator + facade emitter both read schema.contextArguments() rather than re-classifying, restoring the "single producer" framing the load-bearing-classifier annotations promised), dead graphitronContextCall parameter dropped from DataLoaderFetcherEmitter.build plus its three TypeFetcherGenerator call sites, CallParam lifted to carry TypeName javaType so ArgCallEmitter’s two Context arms read identical structural data instead of one round-tripping through `ClassName.bestGuess, body-string assertion on the pipeline test deleted (covered by L5 compile + L6 round-trip), Class<T> expectedType slot dropped from getContextArgument and the Java cast moved to the generated call site ((String) graphitronContext(env).getContextArgument(env, "userId")) ; the factory’s typed parameter list IS the load-bearing diagnostic and the runtime expectedType.cast was redundant ceremony; Classification.resolved switched from Map.copyOf to Collections.unmodifiableMap(new LinkedHashMap<>(…)) so the alphabetical TreeMap iteration order survives the defensive copy across JVM hash seeds): single-tenant slice of R45 lands the sealed contract + the schema-driven factory so the multi-tenant rescope can layer tenant-column classification, byTenant overload, per-loader name partitioning, and the @tenantId directive on top of a stable baseline. Out of scope (called out, all reserved for R45): tenant column Mojo config, tenant-scope classification, byTenant factory overload, DataLoader name partitioning by tenant, @tenantId ARGUMENT_DEFINITION directive. Custom validator factory (<validatorFactory> Mojo element) reserved for R192. Follow-ups flagged by self-review, non-blocking: the singleton’s INSTANCE is publicly callable (the L6 missing-value test reaches in directly to inspect the un-redacted message text; javadoc explicitly frames the throw as server-log surface only). Build green: full mvn -f graphitron-rewrite/pom.xml install -Plocal-db on Java 25; 1870 graphitron tests + 347 example tests pass.

  • R160 (c2dc8d1 C1-C3 sealed FieldClassification + TypeClassification projection families plus CatalogBuilder projector switches landing on LspSchemaSnapshot.Built.{Current,Previous} symmetrically, d02a0bb C4-C5 InlayHints provider with inferred-directive + classification arms plus InlayHintConfig and workspace/didChangeConfiguration push, 039841b C6 classification hover via sealed DeclarationHover parallel dispatch in Hovers.compute, 3d42eeb C7 docs/manual/reference/lsp-inlay-hints.adoc, f647ea5 self-review pass 1 adding the workspace/configuration initialisation pull, the InferredDirectiveArgs indirection, MutationService.tableName parity with QueryService, and a R160 leak from the user-facing doc, 366b07b self-review pass 2 co-locating projection-payload assertions inside GraphitronSchemaBuilderTest’s existing `// ===== <VariantName> ===== classifier blocks via @ProjectionFor plus the ProjectionCoverageTest drift-prevention meta-test): surfaces Graphitron’s inference and classification layers in the editor as inlay hints + rich hover. Three independent client-side toggles all default to false and live under graphitron.inlayHints.inferredDirectives (ghost annotations at bare @table / @field / @reference sites showing the resolved value), graphitron.inlayHints.classification (compact label per field declaration and type declaration), and graphitron.hover.classification (markdown unpacking the variant payload ; table, column, FK chain, target type, error channel, DML verb, …​). Both projection families are sized to distinct hover-payload shapes rather than 1:1 with the generator-side permits, with discriminator fields collapsing siblings that differ only in a label axis (e.g. four MutationField.DmlTableField permits collapse to one DmlMutation(tableName, inputTypeName, errorChannelName, DmlKind) record); the projector’s exhaustive switch over the generator-side permits is the load-bearing coverage contract that fails-to-compile on a new leaf without an LSP-side projection arm. Inferred-directive provenance is read from the live tree-sitter Tree on WorkspaceFile.tree() at request time (the AST asks "did the buffer carry name:?") rather than lifted onto the model ; Provenance discriminators were attempted under a previous design and rolled back (~330 lines across TableRef, ColumnRef, the five @reference-permits, ParticipantRef.CrossTableField); the AST-read keeps the parse boundary closed and adds no model surface. BuildArtifacts shape unchanged: the new fieldClassificationsByCoord / typeClassificationsByName fields live inside LspSchemaSnapshot.Built.{Current,Previous} symmetrically, Workspace.demoteSnapshot preserves them, and stale-snapshot rendering mirrors userArgHover / columnHover’s "prefer stale info over silence" policy. C6 introduces a sealed `DeclarationHover family (FieldDeclarationHover / TypeDeclarationHover) parallel to the directive-arg-keyed Behavior family rather than widening Behavior (the "Capability vs. sealed-switch confusion" principle: Behavior stays directive-argument-binding-shaped, SDL declaration coordinates get their own resolver). Two new @LoadBearingClassifierCheck keys (field-classification-payload-faithful, type-classification-payload-faithful) wear on CatalogBuilder.buildSnapshot with matching @DependsOnClassifierCheck annotations on the three LSP consumers (inferred-directive arm, classification arm, classification hover). Tests: pipeline-tier GraphitronSchemaBuilderTest blocks gain @ProjectionFor-annotated sibling assertions running each canonical fixture through the projector and pinning the projected record type + payload values (24 new @Test methods over ~20 variant blocks); ProjectionCoverageTest walks GraphitronField / GraphitronType sealed leaves and fails on any leaf without a @ProjectionFor cover or a documented NO_PROJECTION_REQUIRED exception; LSP-tier InlayHintsTest covers config gating, the three inferred-directive arms, the classification arm, and Built.Previous stale rendering; DeclarationHoversTest covers field-name and type-name cursor positions, the DmlMutation payload shape, the directive-arg cursor short-circuit, and Unavailable / missing-projection no-ops; GraphitronLanguageServerTest covers the initialisation-time workspace/configuration pull; GraphitronWorkspaceServiceTest covers the push-side didChangeConfiguration parse. Out of scope (called out, not regressed): inlay hints for inferred arguments on directives other than @table / @field / @reference (@nodeId(typeName:), @reference(key:) inference, and future cases extend InferredDirectiveArgs.ENTRIES and gain a renderer arm); inferred return shapes on root fetchers and the inferred join key on @nestingField; a graphitron-shipped editor extension (the LSP exposes the config keys; the editor flips them); inlay hints reflecting unsaved buffer state without a successful generator pass (hints derive from the snapshot, Unavailable means no hints). Follow-ups flagged by review, non-blocking: LspClassificationLabels.{fieldLabel,typeLabel}(model permit) switches are unused at runtime (projection-keyed callers cover both consumers) and can be deleted in a cleanup pass; InferredDirectiveArgs lives in the catalog package with its own string literals rather than under BuildContext’s `ARG_NAME / ARG_PATH, so a rename or new inference rule still maintains two places ; consolidate when a third consumer surfaces. Build green: full mvn -f graphitron-rewrite/pom.xml install -Plocal-db on Java 25; 1869 graphitron tests + 318 graphitron-lsp tests pass.

  • R191 (2e84eff initial-import omnibus carrying spec + implementation + tests): honor @field(name:) for accessor lookup on free-form @record parents on the table-bound and polymorphic-hub branches. FieldBuilder.collectAccessorMatches takes an accessorBaseName parameter and matches against <base> / get<UcBase> / is<UcBase> instead of the SDL field name; deriveAccessorRecordParentSource, resolveRecordParentSource, and derivePolymorphicHubSource thread the value from their callers (fieldName retained for cardinality-mismatch text that quotes the SDL name). The TableBoundReturnType arm at FieldBuilder.java:3700 reuses the already-computed columnName; classifyRecordParentPolymorphicChild reads @field(name:) at :4327-4329 before dispatching to resolvePolymorphicRecordParent. The accessor-rowkey-shape-resolved and accessor-rowkey-shape-resolved-against-hub @LoadBearingClassifierCheck description blocks each gain a sentence: matched accessor’s name is the directive value when present on a free-form @record parent, else the GraphQL field name. The AccessorRef.methodName() value remains the actual reflected method name, so emitters (buildAccessorKeySingle / buildAccessorKeyMany, TypeFetcherGenerator.buildRecordBasedDataFetcher) invoke by name without caring how it was selected. Restores symmetry with the scalar/result branch on the same parent shape (resolveRecordAccessor already threaded the directive value as accessorBaseName). Coverage: pipeline-tier GraphitronSchemaBuilderTest.AccessorDerivedSourceCase adds ACCESSOR_ROWKEYED_FIELD_NAME_REMAPS_ACCESSOR (admit on RemappedPayload with @field(name: "filmRecord"), asserts RecordTableField + AccessorCall.accessor().methodName() == "filmRecord" + cardinality ONE) and ACCESSOR_ROWKEYED_FIELD_NAME_REJECTS_WITHOUT_DIRECTIVE (pin the divergent-accessor-no-directive arm still falls through to the three-option AUTHOR_ERROR); RecordParentMultiTablePolymorphicPipelineTest.childInterfaceField_recordParent_accessorKeyedMany_fieldNameRemapsAccessor covers the polymorphic-hub admit (ListPayload parent, SDL field referrers with @field(name: "films"), asserts hub film + AccessorCall.methodName == "films" + cardinality MANY). New AccessorPayloads.RemappedPayload(FilmRecord filmRecord) fixture record. Out of scope (called out): renaming or restructuring @field(name:), the FK-derivation path (catalog-metadata-driven, structurally indifferent to the directive), and the three-option rejection text. Build green: full mvn -f graphitron-rewrite/pom.xml install -Plocal-db -P!docs on Java 25.

  • R194 (5e5f5e3 builder pass + 5 pipeline cases, a1feace self-review: EmitsPerTypeFile capability + typed CaseFoldCollision + 2 cases, f69b479 preserve CaseFoldCollision under prefixedWith): rejects case-insensitive type-name collisions at build time. GraphitronSchemaBuilder.rejectCaseInsensitiveTypeCollisions runs post-ConnectionPromoter.rebuildAssembledForConnections (rather than post-promote, so the assembled GraphQLSchema typeRefs stay resolvable when a synth Connection is demoted) and case-folds via Locale.ROOT; every member of each case-equivalent group demotes to UnclassifiedType carrying a typed Rejection.InvalidSchema.CaseFoldCollision(group, origin, prefix) with Origin{SDL, SYNTH_CONNECTION, SYNTH_EDGE, SYNTH_PAGE_INFO}. message() specialises the actionable fix hint per origin (@asConnection(connectionName:) for synth arms, generic rename for SDL); validateUnclassifiedType projects one ValidationError per member. The emit-vs-no-emit split is lifted out of GraphitronType onto a new EmitsPerTypeFile capability marker (mirrors SqlGeneratingField / BatchKeyField), implemented by every variant except ScalarType and UnclassifiedType; detector filters via instanceof EmitsPerTypeFile. prefixedWith returns a same-variant CaseFoldCollision with accumulated prefix rather than degrading to Structural, satisfying R58’s typed-rejection-preserved-under-wrap contract (the validator’s prefixedWith("Type 'X': ") is the only path that reaches ValidationError.rejection). Coverage: GraphitronSchemaBuilderTest.CaseInsensitiveTypeClashCase parameterised over 7 SDL fixtures (SDL_VS_SDL, SYNTH_VS_SYNTH, SDL_VS_SYNTH, SYNTH_EDGE_VS_SDL, SYNTH_PAGE_INFO_VS_SDL, THREE_WAY_GROUP, NO_CLASH_BASELINE); RejectionRenderingTest.prefixedWithPreservesCaseFoldCollisionTypedFields pins single + re-prefixed paths; RejectionSeverityCoverageTest + SealedHierarchyDocCoverageTest pick up the new permit; typed-rejection.adoc carries the prose + mermaid-class entry. Out of scope (called out): legacy MakeConnections / graphitron-schema-transform classifier, auto-mangling colliding names, federation cross-subgraph clashes, derived-filename collisions beyond the type-name stem. Build green: full mvn -f graphitron-rewrite/pom.xml install -Plocal-db on Java 25.

  • R196 (c42ed64 route Workspace recalc through listener seam): lifts "drain follows enqueue" from author-discipline at three of six Workspace public mutators (didOpen, didChange, didClose paired with explicit publishDiagnosticsForRecalculate(); setBuildOutput, demoteSnapshot, markAllForRecalculation unpaired and reachable from DevMojo.regenerate / rebuildCatalog after schema-file + classpath watcher events) to a structural invariant across all six. New private enqueueAndNotify(Runnable) helper performs the queue mutation under lock and fires a single-slot volatile Runnable recalculateListener after lock release; the six public mutators route through it (setBuildOutput / demoteSnapshot transitively via markAllForRecalculation), so toRecalculate writes only happen inside the funnel. GraphitronTextDocumentService.setClient registers this::publishDiagnosticsForRecalculate as the listener; the explicit publish calls in didOpen/didChange/didClose are gone, and the build-trigger paths now publish diagnostics on save without waiting for the next keystroke. didClose’s "clear-for-closed-file" one-shot stays, repositioned before the workspace call to keep the seam uniform (the only direct client call in the LSP service is the close-clear; everything else flows through the listener). Lock-release-before-listener-fire is deliberate: it keeps a build swap on the watcher thread and an editor event on the lsp4j thread from serialising on `lock through the heavy Diagnostics.compute body; idempotency on the drain side (a second drainRecalculate after the first empties the queue returns an empty list) makes "listener fires twice for two mutations interleaved with one drain" a no-op rather than a hazard. Tests: unit-tier WorkspaceTest.everyPublicQueueMutatingMethodFiresTheListener parametrises over the six mutators asserting listener-fire count delta of exactly 1; recalculateListenerDefaultsToNoOpForTestHarnesses pins that mutators on a workspace without setRecalculateListener do not NPE; drainRecalculateIsIdempotentOnEmptyQueue pins the single-extraction property the listener path depends on; demoteSnapshotOnNoOpDoesNotFireListener parametrises over the two no-op starting states (Unavailable, Built.Previous) pinning the only public-mutator path that returns without firing the listener ; the exception branch of the otherwise-uniform "every public mutator notifies" rule. Pipeline-tier BuildTriggerPublishesDiagnosticsTest captures publishDiagnostics calls on a stub LanguageClient and drives the three-step sequence (didOpen empty → setBuildOutput with validator error → setBuildOutput with empty report); pre-R196 the second assertion failed because the listener didn’t fire. Retires the LSP-side half of R149’s deferred end-to-end publish-diagnostics wire test (the producer-side buildOutput() report-population test stays under R149). Out of scope (called out, not regressed): multi-consumer fan-out (one consumer today; lift when a second appears); richer event shapes (sealed RecalculateEvent discriminating editor / build / demotion causes ; drain is cause-agnostic, sub-taxonomy carries no information consumers act on differently); non-DevMojo callers of markAllForRecalculation/demoteSnapshot/setBuildOutput. Build green: full mvn -f graphitron-rewrite/pom.xml install -Plocal-db on Java 25.

  • R197 (a755e39 wire LSP didSave to in-process regen trigger, 30473f9 In Progress → In Review): wires GraphitronTextDocumentService.didSave to a Consumer<String> onSchemaSaved listener constructor-injected via GraphitronLanguageServer and propagated through DevServer from DevMojo. DevMojo.buildSaveListener(suffixes, debounce, regen) (package-private static) filters URIs by RewriteContext.schemaFileExtensions() and schedules regenerate(workspace) through the same schemaDebounce the FS watcher uses, so editor saves and watcher events coalesce on a single regen. DebounceExecutor construction hoisted from startSchemaWatcher up into execute() so the listener can be built before bindServer. Headless LSP-only use sites (standalone Launcher, existing TextDocumentServiceTest fixtures) keep their behaviour via no-arg / one-arg constructor defaults that pass a no-op Consumer. Workspace is unchanged: the seam lives at the language-server boundary, not in the workspace, so extension-set ownership stays in the Mojo and the LSP module remains suffix-agnostic. Tests: TextDocumentServiceTest.didSave_invokesListenerWithUri pins the URI-typed listener contract, didSave_noopWhenListenerAbsent pins the headless contract, DevMojoTest.saveListener_schemaSuffixSchedulesRegen covers the suffix filter and debounce scheduling. Docs: getting-started.adoc dev-loop prose calls out the dual-path model (LSP didSave primary, FS watcher headless fallback) and the Mermaid diagram gains the LSP didSave → dispatch arrow; DevMojo class-level javadoc updated to match. Out of scope (called out, not regressed): didChange regen (save is the user’s intentional commit point; mid-typing buffers are partial SDL); replacing SchemaWatcher (stays as headless fallback and remains the only path for classpath watching); FSEvents native backend (deferred under R198’s out-of-scope list). Build green: full mvn -f graphitron-rewrite/pom.xml install -Plocal-db on Java 25.

  • R198 (b39d1c5 lift SchemaWatcher FS-bound tests to synthetic dispatch, e10785c self-review: package-private dispatch + run()-time polling hint): lifts the five SchemaWatcherTest cases that depended on real-FS event delivery (modifyingGraphqlsFile_firesCallback, deletingGraphqlsFile_firesCallback, rapidWrites_firesCallbackOnce, newSubdirectory_isRegisteredAndFiresCallback, and one CatalogRefreshTest case) onto synthetic WatchEvent values driven directly into SchemaWatcher.dispatch. macOS’s JDK ships PollingWatchService with a hardcoded 10 s period (since SensitivityWatchEventModifier was removed in JDK 21), so the suite’s 1.6 s wait could never observe a real-FS event; the assertions the failing tests made were unit-tier invariants on dispatch (suffix filter, OVERFLOW reschedule, on-the-fly subdirectory registration) dressed up as integration tests of the JDK’s WatchService. writingGraphqlsFile_firesCallback survives as the Linux-only inotify smoke (@EnabledOnOs(LINUX)); nonGraphqlsFile_noCallback deleted as a duplicate of dispatch_ignoresUnconfiguredSuffix; graphqlsWriteDoesNotFireClasspathWatcher folded into the synthetic shape. New watchServiceBackend_matchesExpectedPerOs probe pins PollingWatchService on macOS and LinuxWatchService on Linux, so a future JDK shipping an FSEvents-backed WatchService would fail loudly and the Linux-only gate would get revisited. Runtime hint on the first iteration of SchemaWatcher.run() emits two LOGGER.info lines when the underlying WatchService is polling-based (JDK fact + LSP recommendation as separate lines so either can be revised independently); fires once per watcher lifetime in production, silent in synthetic-dispatch tests. SchemaWatcher.dispatch stays package-private; the lone cross-package consumer (CatalogRefreshTest in ..maven.dev) routes through a new test-only DispatchTestSupport class under src/test/java/…​/maven/watch/, matching the existing watchedDirs() test-seam precedent. Test counts: SchemaWatcherTest 11→11, CatalogRefreshTest 2→2. Out of scope (called out, not regressed): swapping the WatchService backend to a native FSEvents library (would add JNA to the plugin’s classpath; size separately); the LSP-driven regen path itself (R197); Linux aarch64 / Windows verification (R89). Build green: full mvn -f graphitron-rewrite/pom.xml install -Plocal-db on Java 25.

  • R189 (666f0fb admit FK-target @nodeId input fields on @mutation, 9524885 In Progress → In Review): admit InputField.ColumnReferenceField / CompositeColumnReferenceField (FK-target @nodeId(typeName: T) pointing at another @table’s NodeType, classified to `Resolved.FkTarget.DirectFk) on every non-UPSERT @mutation verb. The reference carriers' liftedSourceColumns live on the input’s own table, the extraction is narrowed to CallSiteExtraction.NodeIdDecodeKeys, and the emitters bind decoded keys against liftedSourceColumns positionally ; the same shape the same-table ColumnField / CompositeColumnField NodeId carriers already drive. MutationInputResolver.resolveInput drops the deferred R24 rejection for these two carriers on INSERT / UPDATE / DELETE and removes the misleading "tracked in R24’s scope" hand-off; UPSERT stays refused at the kind gate (R145). InputField.LookupKeyField and InputField.SetField widen permits to include both reference carriers. Load-bearing: EnumMappingResolver.buildLookupBindings adds case ColumnReferenceField / case CompositeColumnReferenceField arms emitting MapGroup / DecodedRecordGroup over liftedSourceColumns(); without this the mutation-input.where-columns-cover-pk check would silently under-count reference contributions and fire false "missing PK column" rejections on schemas whose FK column covers the PK. TypeFetcherGenerator extracts five new helpers (emitSetMapPuts, emitSetExcludedPuts, emitSetVColNameAdds, emitSetBulkCellAdds, emitSetVFieldPuts plus setFieldColumns / setFieldNodeIdExtraction dispatchers) that replace eight (InputField.ColumnField) sf casts over tia.setFields() across the UPDATE / UPSERT-SET / bulk-UPDATE paths; the INSERT-path helpers (anyNodeIdCarrier, buildInsertColumnList, buildPerCellValueList, buildInsertDecodeLocals) widen their carrier-shape switches with mirroring reference arms. Three @LoadBearingClassifierCheck annotations are restated: mutation-input.where-columns-cover-pk (semantic ; filter-column contributions now include liftedSourceColumns() from the two reference carriers), mutation-input.update-set-fields-equal-value-marked (wording ; admissible-carrier set widened), mutation-input.lookup-binding-decoded-record-arity-matches-carrier-columns (wording ; arity guarantee extended to the FK-target composite arm). Tests: pipeline-tier MutationDmlNodeIdClassificationTest adds eight R189 cases ; arity-1 INSERT admission, arity-1 DELETE PK-coverage, arity-1 UPDATE with @value SET field, composite-key DELETE through reordered_pk_parent / reordered_fk_child, composite-key INSERT, and three bracketing rejection cases: fkTargetNodeIdRef_pkCoverage_underCount_negativeRejectionFixture (the load-bearing assertion that pins the validator widening ; without step 4 this exact shape would fire a false "missing: id_1" rejection that slips past both compilation and execution tiers), fkTargetNodeIdRef_pkCoverage_genuinelyMissing_rejected (contrast fixture confirming the canonical missing-PK rejection still fires), and fkTargetNodeIdRef_upsert_stillRejected_underR144 (UPSERT refusal at the kind gate supersedes admission). Out of scope (called out, not regressed): Resolved.FkTarget.TranslatedFk admission (the parent_node + child_ref shape where the FK targets a non-PK NodeType keyColumn ; BuildContext.java:1846-1849 continues to produce InputFieldResolution.Unresolved); UPSERT (R145 territory); the output-side JOIN-with-projection NodeId encoding R24 originally hand-off pointed at. Build green: full mvn -f graphitron-rewrite/pom.xml install -Plocal-db on Java 25.

  • R187 (8e70b8c In Review: nested @service arg-mismatch diagnostic at non-SOURCES shapes): the unresolved-@service-parameter discriminator in ServiceCatalog now selects the arg-mismatch arm whenever the parameter type is not SOURCES-adjacent, instead of gating that arm on parentPkColumns.isEmpty(). Nested fields with a non-container parameter (LocalDate, String, Integer, …​) whose name doesn’t match any GraphQL argument no longer fall through to "unrecognized sources type" ; they get the actionable "does not match any GraphQL argument or context key" hint with available args + argMapping suggestion. The DTO-shape rejection arm is gated to nested coordinates only (!parentPkColumns.isEmpty()), preserving the root + List<DTO> → arg-mismatch precedence pinned by dtoSources_onRootField_pointsAtArgCtxMismatch (this is the Spec’s "Precedence between DTO-hint and arg-mismatch" rule; the implementation deviates from Spec step-3’s literal wording, which would have moved the DTO arm up unconditionally and broken that test, but matches the Spec’s stated intent). Unit-tier ServiceCatalogTest rewrites reflectServiceMethod_unrecognisedParam_onChildField_* (now asserts arg-mismatch on getWithUnknown(Object) under non-empty parentPkColumns) and adds reflectServiceMethod_nonSourcesPayloadOnChildField_pointsAtArgCtxMismatch pinning the LocalDate reproduction. Pipeline-tier GraphitronSchemaBuilderTest adds SERVICE_ON_CHILD_WITH_NON_SOURCES_PARAM_NAME_MISMATCH_REJECTED alongside the existing root-coordinate rejection case. Follow-up R193 (Backlog) captures the architectural smell flagged by the principles-architect review: the discriminator now has two consumers (R185 + R187) with subtly different precedence; a sealed UnresolvedParam classifier would consolidate the precedence in one place.

  • R185 (b6539b9): narrow ServiceCatalog.looksLikeSourcesShape to List<RowN> / List<RecordN> only. A root @service whose Java parameter is a List<XRecord> (a concrete TableRecord subtype) under a name that doesn’t match any GraphQL argument was getting the "`@service` at the root does not support List<Row>/List<Record>/List<Object> batch parameters" diagnostic, shadowing the actionable arg-mismatch diagnostic that lists available argument names and suggests argMapping. List<XRecord> at root is the canonical InputBeanResolver shape, so a plain name typo collided with the Sources-shape exception. The concrete TableRecord branch is removed from looksLikeSourcesShape; only the two anonymous-key shapes (RowN, RecordN) keep producing the Sources-batch diagnostic. The user-visible diagnostic also drops /List<Object> to match what now triggers it. Pipeline-tier SERVICE_AT_ROOT_WITH_TABLERECORD_PARAM_NAME_MISMATCH_REJECTED (GraphitronSchemaBuilderTest) asserts the arg-mismatch diagnostic wins for List<FilmRecord> under a mismatched name; the two existing RowN-element cases (SERVICE_AT_ROOT_WITH_SOURCES_PARAM_REJECTED, MUTATION_SERVICE_WITH_SOURCES_PARAM_REJECTED) keep passing and lock the predicate against opposite regression.

  • R183 (3ccd1ea GitLab pipeline targets graphitron-rewrite reactor on tags, bf5d2c3 provision postgres service + run codegen + tests in publish, 40790fc apt-get gcc so graphitron-lsp’s native build works, 236860f parameterize test.db.url so CLI -D reaches surefire, dcdf0d2 self-review cleanups before re-handoff): replaces the legacy reactor’s snapshot-on-default-branch + release-on-tag publish pipeline with a release-only pipeline targeting the rewrite reactor. publish:snapshot is deleted outright; default-branch pushes (including GitHub → GitLab mirror sync) no longer fire any deploy, eliminating the active hazard of legacy 9-gitlab-SNAPSHOT artifacts accumulating in the Sikt GitLab Packages registry on every mirrored commit. publish:release now runs mvn -f graphitron-rewrite/pom.xml versions:set -DnewVersion=$VERSION -DgenerateBackupPoms=false -DprocessAllModules=true (the processAllModules flag is load-bearing: without it the aggregator pom updates but child modules keep ${revision} and the deploy publishes mismatched coordinates) followed by mvn -f graphitron-rewrite/pom.xml clean deploy -P gitlab,local-db -Ddb.url=jdbc:postgresql://postgres:5432/rewrite_test -Dtest.db.url=jdbc:postgresql://postgres:5432/rewrite_test. The tag regex widens to ^v\d+\.\d+\.\d+(-RC\d+)?$ to accept the -RC<n> suffix Maven Central consumers depend on. A new gitlab profile in graphitron-rewrite/pom.xml declares the GitLab Packages <repository> (no <snapshotRepository> ; the rewrite parent’s invariant per graphitron-rewrite/docs/README.adoc Publishing: an accidental mvn deploy on 10-SNAPSHOT must fail fast) plus deployAtEnd=true and the sources-jar attachment; the root-pom legacy gitlab profile is unreachable from the new caller and dies with the legacy reactor under R182. The pipeline image bumps from maven:3.9-eclipse-temurin-21 to maven:3.9-eclipse-temurin-25 to satisfy the parent pom’s requireJavaVersion enforcer rule. In-runner Postgres: publish:release provisions a postgres:18-alpine service (alias postgres, POSTGRES_HOST_AUTH_METHOD=trust), apt-get install`s `postgresql-client + gcc in the runner, and applies graphitron-rewrite/graphitron-sakila-db/src/main/resources/init.sql via psql. The local-db profile is activated alongside gitlab so jOOQ codegen reads against the live service rather than spinning up a Testcontainer (no Docker-in-Docker). The gcc install covers graphitron-lsp’s `build-native.sh invocation at generate-resources that compiles the tree-sitter native lib bundled into the published graphitron-lsp jar at lib/linux-x86_64/libtree-sitter-graphql.so. Surefire parameterisation: graphitron-sakila-example’s `local-db profile lifts test.db.url / test.db.username / test.db.password from literal <systemPropertyVariables> entries into pom <properties> (localhost defaults unchanged for local devs) so CLI -Dtest.db.url=…​ overrides reach surefire’s fork via property interpolation; without this, the CI’s overrides reached jOOQ codegen but the tests still tried localhost. Tests run in the publish pipeline as a deploy-boundary sanity check now that a real Postgres is in the runner anyway; the earlier "skip tests, GitHub gates them" rationale was load-bearing only when Docker-in-Docker was the alternative, and the GitHub publish workflow on main is itself in flux per R182 so cannot be relied on as a gate. Out of scope (called out, not regressed): retiring the legacy reactor or the root-pom gitlab profile (R182 collapses both); Maven Central publishing on main (separate fix once R182 retires the legacy reactor; this item only touches GitLab); cleanup of existing junk 9-gitlab-SNAPSHOT artifacts already deposited (manual GitLab UI task); a release-cli job creating a GitLab Release object attached to the tag (cosmetic ; consumers depend by Maven coordinate). Verification deferred to a throwaway-tag push (e.g. v10.0.0-RC0) confirming the five expected coordinates deploy to GitLab Packages (no.sikt:graphitron-rewrite-parent pom, no.sikt:graphitron-javapoet, no.sikt:graphitron, no.sikt:graphitron-maven-plugin, no.sikt:graphitron-lsp) while the six maven.deploy.skip=true modules stay out of the registry, and a default-branch push triggers no publish job. Build green locally: full mvn -f graphitron-rewrite/pom.xml install -Plocal-db on Java 25 with the local-db profile pointing at a native Postgres.

  • R178 (9ee35d9 step 1 DML-only cutover, e41ddb4 step 2a wire transportForParent through selectErrorsTransport, 64dfa33 step 2b @service-carrier classifier wiring, fe270f9 step 3 retire SettKvotesporsmal-bug carrier-walk consultations, c44193b Phase 4 slice 1 delete dead writers, 7cbe4b6 slice 2 retire transitional consultations, 1211e6d slice 3 non-DELETE structural detection, e665e11 slice 4 DELETE arm structural detection, 1e71906 slice 5 lift structural carrier scan to BuildContext, d0de975 slice 6 retire verbless walk’s carrier-walk consultation, 475e2ec slice 7a retire carrier-walk methods + load-bearing re-anchors, a1bbbcd slice 7b retire sealed model types, e06f1c8 slice 7c retire SingleRecordIdentityField permit, 83d2182 slice 7d cosmetic, 4a67b26 self-review cleanup, 1ddb22b rename "Carrier" identifiers to "Payload", f42b819 final self-review, 575cd9b In Progress → In Review): collapses the parallel single-record carrier walk to the unified SourceKey + R96 reflection path. Deletes seven sealed hierarchies (SingleRecordCarrierResolution, SingleRecordCarrierShape, CarrierFieldRole, DataElement, BuildContext.tryResolveSingleRecordCarrier overloads + classifyCarrierField, BuildContext.carrierProducerRegistry, the four register*CarrierDataField writers, ChildField.SingleRecordIdentityField) and three load-bearing keys (single-record-carrier-shape.roles-exhaustively-classified, carrier-data-field.single-producer-kind, carrier-data-field.service-producer-strict-return). Adds two sibling ProducerBinding arms (DmlEmitted(TableRef, DmlKind, Cardinality), ServiceEmitted(TableRef, Cardinality, producer-site)) grounded by R96 in dedicated memos, observed structurally on the payload SDL so the carrier walk’s forbidden-directives loop never fires at observation time (the SettKvotesporsmal bug’s mechanism). A builder-internal sealed result BuildContext.DmlPayloadScan (Admit(dataField, DmlElementKind) | Reject(reason) | NotApplicable) replaces the carrier walk for the @mutation classifier and MutationInputResolver. The @field directive on a non-$source payload data field no longer hard-rejects ; the SettKvotesporsmal contract pin: with and without @field(name:), semantically identical schemas classify identically. The mutation-dml-record-field.data-table-equals-input-table invariant re-anchors on the smaller requireDmlDataTableMatchesInputTable helper covering both DELETE and non-DELETE arms; the error-channel.local-context-transport invariant re-anchors on FieldBuilder.detectStructuralDmlErrorChannel (the new sole producer of ErrorChannel.LocalContext on DML payloads). The unit-tier ErrorsTransportSelectionTest pins the errors-field defaulting rule table; the pipeline-tier SettKvotesporsmalShapeRegressionTest pins both the identical-classification and the diagnostic-wording contracts. The three payload-returning mutation permits (MutationDmlRecordField, MutationBulkDmlRecordField, MutationServiceRecordField) survive structurally; what changes is their classification path. Tests: unit-tier ErrorsTransportSelectionTest (8 cases pinning every branch of FieldBuilder.selectErrorsTransport); pipeline-tier SettKvotesporsmalShapeRegressionTest (3 cases: with-@field admits, without-@field admits identically, ClassBacked return-mismatch diagnostic cites the payload class not the inner record); pipeline-tier SingleRecordPayloadPipelineTest (renamed from SingleRecordCarrierPipelineTest, 33 cases including R178 admits of @field and @deprecated on the data field); execution-tier SingleRecordPayloadDmlTest (renamed, durability pins intact); the MUTATION_DML_RECORD_FIELD / MUTATION_BULK_DML_RECORD_FIELD / DELETE-carrier / SINGLE_RECORD_IDENTITY_FIELD_ORPHAN rows in GraphitronSchemaBuilderTest retarget to the unified path. Phase 5 deferral (called out in spec, separable follow-up): the emit-side migration to Wrap.Row + Reader.ColumnRead ships under R180 (record-parent-column-read-helper); Phase 5 survivors (ChildField.SingleRecordTableField, FromReturning, Reader.ResultRowWalk, the five FetcherEmitter.buildSingleRecord methods, three remaining load-bearing keys) stay alive on the producer side until R180 lifts them. Out of scope (called out, not regressed): RecordBindingResolver walk reshape (R178 adds one new producer arm but does not change the resolver’s model), R156’s NodeId encoder chain, two-step DML emit shape, wire-format serialization, the @table-parent child-classification path. Build green: full mvn -f graphitron-rewrite/pom.xml install -Plocal-db on Java 25.

  • R184 (05e21d5 Graphitron.newExecutionInput factory + getContextArgument default, 5d308c2 In Progress → In Review): collapses the two pieces of per-request boilerplate every graphitron app repeats (.graphQLContext(b → b.put(GraphitronContext.class, ctx)) to thread the typed context key generated fetchers look up, plus .dataLoaderRegistry(new DataLoaderRegistry()) to satisfy graphql-java’s always-required registry) into a single generated factory entry point on the emitted Graphitron facade. GraphitronFacadeGenerator now emits two static newExecutionInput overloads alongside buildSchema: newExecutionInput(GraphitronContext context) returns ExecutionInput.newExecutionInput().graphQLContext(b → b.put(GraphitronContext.class, context)).dataLoaderRegistry(new DataLoaderRegistry()), and the single-tenant convenience newExecutionInput(DSLContext dsl) delegates to newExecutionInputGraphitronContext) env → dsl). To make the lambda form bind to GraphitronContext’s SAM rather than infer as `Function<DataFetchingEnvironment, DSLContext>, GraphitronContextInterfaceGenerator flips getContextArgument from ABSTRACT to DEFAULT with body return env.getGraphQlContext().get(name); (matching the legacy DefaultGraphitronContext); getDslContext is now the only abstract method on the interface. The interface is deliberately not annotated @FunctionalInterface (a permanent contract that would block ever adding another abstract method); instead a pipeline-tier test pins the count of abstract methods to one, so any future generator change that adds a second abstract method fails the test in tandem with the sakila example’s compile of the lambda form. Tests: pipeline-tier GraphitronFacadeGeneratorTest asserts the two newExecutionInput overloads exist with (GraphitronContext) and (DSLContext) parameter lists, both returning graphql.ExecutionInput.Builder, both public static; pipeline-tier GraphitronContextInterfaceGeneratorTest asserts getContextArgument carries DEFAULT (not ABSTRACT) with the right body and that the emitted interface has exactly one abstract method; compilation-tier NewExecutionInputFactoryTest in graphitron-sakila-example pins three graphql-java contract facts (.dataLoaderRegistry(custom) replaces the factory’s fresh registry rather than merging ; exercises the user-visible override path; the (DSLContext) overload defaults to an empty registry; the (GraphitronContext) overload places the context under the typed GraphitronContext.class key generated fetchers read from). Execution coverage comes from the sakila example’s rewritten GraphqlResource.execute: the six-line builder collapses to three via Graphitron.newExecutionInput(new AppContext(…​, and every existing execution test in graphitron-sakila-example runs through that method. Docs updated to point at the factory (getting-started.adoc hello-world + multi-tenant + DataLoader-registry sections; runtime-extension-points.adoc registration snippet; emitted facade + interface javadocs). Out of scope (called out, not regressed): no federation overload of newExecutionInput (ExecutionInput carries no federation-specific wiring; the same factory serves both schema flavours); no change to DataLoader registration mechanics (generated fetchers continue to populate the registry lazily via computeIfAbsent); no new module, no change to dependency graph; getTenantId and getValidator defaults unchanged (they were already default-method shaped). Build green: full mvn -f graphitron-rewrite/pom.xml install -Plocal-db on Java 25.

  • R179 (ef0af6c delete ResultAssembly; service success arm is universal passthrough, a3bde69 self-review cleanup: retire stale ResultReturnType coverage claim): deletes the ResultAssembly + ResultSlot model types, the resolveServiceResultAssembly + buildResultAssemblyBeanArm classifier in FieldBuilder, and the buildSuccessPayload / buildSuccessPayloadCtor / buildSuccessPayloadSetters emit helpers in TypeFetcherGenerator. The success arm collapses to a single universal-passthrough body: T result = service.method(…​); return success(result);. The architectural constraint the deletion enforces is that the generator does not construct output DTOs on the happy path: per-field wiring (graphql-java child fetchers) projects SDL fields off the parent’s domain return, and a SDL-declared CreateFilmPayload { film: Film, errors: [Error] } does not need a Java twin. The catch-arm payloadFactoryLambda is the only remaining DTO-construction site, and the boundary is structural ; error-routed lists are produced inside the generator-owned try block where per-field wiring has no parent value to project from. The four service-backed Field records (Query/Mutation × Table/Record) lose their trailing Optional<ResultAssembly> resultAssembly component; the four arms in classifyQueryField / classifyMutationField route through buildServiceField, whose BiFunction<channel, assembly, …​> collapses to Function<channel, …​>. A new surviving classifier check (checkServiceReturnMatchesPayload in FieldBuilder) replaces the three Assembly-specific reject messages with the single legacy-passthrough wording: "@service method '<cls>.<method>' must return '<sdlPayloadTypeName>' to match the field’s declared payload type ; got '<method.returnType()>'". Stale-doc cleanup spans 12 sites (FieldBuilder LoadBearingClassifierCheck descriptions ×2 + ServiceCatalog.reflectServiceMethod description retired ResultReturnType coverage claim, FieldBuilder javadoc ×4, model-class javadocs on DefaultedSlot / PayloadConstructionShape / NonBoundSetter / ErrorsSlot, ServiceDirectiveResolver / MutationInputResolver ResultReturnType arm comments, SettKvotesporsmalShapeRegressionTest + TestServiceStub javadoc). R169 (service-domain-object-execute-coverage, Backlog) deletes in the same commit per workflow.adoc’s "supersession before shipping" Discarded pattern: its entire scope was execute-tier coverage for the now-deleted Assembly arm. One fixture addition beyond the spec’s enumeration: TestServiceStub.runSakWithInputBean(TestInputBean) returning SakPayload, replacing the String-returning stub that the pre-existing FetcherPipelineTest.inputRecord_validatorPreStep_* test had relied on the Assembly arm to admit; the new stub preserves the test’s intent (validator pre-step on Input-typed arg with VALIDATION-bearing channel) without losing the R150 input-bean classification dimension. LoadBearingGuaranteeAuditTest net stays balanced: both payload-construction. producers retain surviving consumers (catch-arm payloadFactoryLambda + validator pre-step declareEarlyPayloadFromErrors / declareEarlyPayloadSetters); no producer becomes orphaned. *Out of scope (called out, not regressed): inlining the success-arm local (kept for catch-arm try-block uniformity); redesigning the catch-arm payloadFactoryLambda path (the only remaining DTO-construction site, principled and unchanged). Build green: full mvn -f graphitron-rewrite/pom.xml install -Plocal-db on Java 25.

  • R177 (1aba97f child @service rows-method preserves specific XRecord type, de5ed05 self-review: name the third consumer in RowsMethodShape class doc): narrows the child-@service emit site so the rows-method’s V and the matching DataLoader<K, V> value type both honor the TableBoundReturnType classifier guarantee instead of widening to raw org.jooq.Record. RowsMethodShape.strictPerKeyType returns tb.table().recordClass() for the TableBoundReturnType arm (was: a RECORD constant, now deleted); TypeFetcherGenerator’s `ChildField.ServiceTableField arm threads stf.returnType().table().recordClass() as the single servicePerKeyType local into both buildServiceDataFetcher (the DataLoader<K, V> typing line) and buildServiceRowsMethod (the Map<K, V> / List<V> return-type line), so the typed loader populates from the rows-method without a wildcard or defensive cast. ServiceDirectiveResolver.validateChildServiceReturnType’s `@LoadBearingClassifierCheck description is rewritten to name both emit-site consumers explicitly (rows-method .returns(…​) and the typed DataLoader<K, V>), capturing that the strict TypeName.equals arm is now load-bearing for the typed loader’s compile via Java generics invariance ; not just structural symmetry; a new @DependsOnClassifierCheck on buildServiceDataFetcher pins the data-fetcher side to the same key, and the existing annotation on buildServiceRowsMethod is updated to reflect the narrower V. RowsMethodShape’s class-level docstring lifts the consumer count from two to three and names the DataLoader-typing line with the Java-generics-invariance reason. The diagnostic wording on the validator’s strict-return rejection shifts from `must return 'List<Record>' to must return 'List<LanguageRecord>' (the specific record class for the field’s bound table). Tests: six ServiceTableField unit assertions in TypeFetcherGeneratorTest flip from org.jooq.Record to the specific FilmRecord (positional + mapped, single + list, on both data-fetcher and rows-method return); three new R177-axis enum rows in GraphitronSchemaBuilderTest paired with three new fixtures in TestServiceStub cover the migration arm (List<List<Record>> was accepted, now rejected), acceptance arm (List<List<LanguageRecord>> was rejected, now accepted), and cross-record regression (List<List<FilmRecord>> stays rejected); CHILD_SERVICE_TABLE_BOUND_WRONG_RETURN_REJECTED’s diagnostic assertion updated to the narrowed wording; pipeline-tier `TestFilmService.getFilms tightened to List<List<FilmRecord>> with the paired pipeline assertion flipped; new positive compile-tier fixture Film.languageByService in graphitron-sakila-example backed by FilmService.languageByService returning Map<Record1<Integer>, LanguageRecord> makes mvn compile -pl :graphitron-sakila-example the load-bearing guarantee against future re-widening of the emit site. Out of scope (per spec, not regressed): ChildField.ServiceRecordField’s `elementType() fallback path (the asymmetry is principled ; ServiceRecordField carries the broad ReturnTypeRef sealed root rather than TableBoundReturnType, and strictPerKeyType can return null for that variant); the SourceKey.Wrap.TableRecord source-side typing pipeline (R177 brings the target side into alignment with what the source side already does); the rows-method’s outer container shape (Map vs List, single vs list cardinality ; outerRowsReturnType continues to wrap whatever perKey it’s handed); SplitRowsMethodEmitter (the ChildField.SplitTableField arm stays raw Record, explicit non-goal). Build green: full mvn -f graphitron-rewrite/pom.xml install -Plocal-db on Java 25.

  • R96 (ab18e8c ProducerBinding sealed taxonomy + RecordBindingMismatch rejection, d098d76 RecordBindingResolver ; reflection-driven SDL → backing-class walker, bc1e457 wire RecordBindingResolver into TypeBuilder + reflection-derived dispatch, 2a19d40 transitional dispatch: gate result-axis walker on @record + directive fallback, 37ee435 flatten RecordBindingMismatch to RecordBindingMultiProducer + doc coverage, 4127f3d pipeline-tier tests for record-binding behaviour, ec229f1 self/arch review fixes): replaces the directive-driven recordBackingClasses population in TypeBuilder with a reflection-driven walk that grounds at root producers (@service returns, @table resolutions, @tableMethod returns) and propagates through parent-accessor return types to a fixed point. RecordBindingResolver.resolveAll() accumulates every observed binding per SDL type into a collection set keyed on either axis (result / input); the per-type set folds at the end of the walk into either an agreed Class<?> (singleton) or Rejection.AuthorError.RecordBindingMultiProducer (more than one distinct class). Cycle protection is fixed-point iteration over the per-type collection sets rather than recursive descent: each pass snapshots the currently-folded bindings, walks each parent’s accessor edges (getX / isX / x / public field-read, with optional DataFetchingEnvironment param), and adds new (reflectedClass, site) pairs to the per-type sets until a pass produces no new entry; a 1000-pass safety bound surfaces non-convergence as IllegalStateException. The new sealed ProducerBinding taxonomy (RootService / RootTable / RootTableMethod / ParentAccessor) carries the typed list inside the rejection so downstream tooling switches on the arm rather than parsing prose. Three-variant directive-ignored warning emitted at a single post-classification site (TypeBuilder.emitDirectiveIgnoredWarnings): Matches ("redundant; remove it") when the directive’s className equals the reflected class or the directive carries no className; Disagrees ("graphitron derives <X>") when they differ, naming the reflected class; Shadowed by @table (input types only) when @table co-occurs and grounds the binding. Variant precedence: Shadowed > Matches/Disagrees; a multi-producer rejection suppresses the warning entirely (error supersedes warning at the same site). The legacy @table + @record input-side warning at TypeBuilder.java:826-831 is removed; the redundancy signal is now carried by the Shadowed by @table variant. Load-bearing pin: producer @LoadBearingClassifierCheck(key = "record-binding.producer-agreement") on RecordBindingResolver (description names the two pure-function commitments riding under the check: ServiceCatalog.resolveTableByRecordClass derives TableRef purely from cls, and a Java record’s component list is a pure function of cls) pairs with @DependsOnClassifierCheck on FieldBuilder.resolveRecordAccessor. Tests: pipeline-tier R96RecordBindingPipelineTest (six cases: Matches with @service producer; Disagrees with directive lying about class; Shadowed-by-table on input; unreachable type falls back to directive’s className for backward compat; plain SDL carrier preserved through R75’s PojoResultType.NoBacking promotion; multi-producer disagreement surfaces typed RecordBindingMultiProducer rejection); RejectionSeverityCoverageTest adds the new permit to its sample factory; GraphitronSchemaBuilderTest’s `TABLE_PLUS_RECORD and SERVICE_WITH_RECORD_BACKING_CLASS_MISMATCH_REJECTED cases update to assert R96 semantics (table wins on input + Shadowed-by-table warning; service-with-record-mismatch corrects silently with Disagrees warning instead of UnclassifiedField); ErrorChannelClassificationTest.unTypedRecordPayload_* flips from "produces no channel" to "produces channel from reflected producer" ; a correctness improvement R96 introduces because @record without className now grounds via the producer’s return type rather than falling through to NoBacking; SealedHierarchyDocCoverageTest passes against the updated typed-rejection.adoc (new fourth AuthorError arm documented at chapter prose + mermaid class diagram). The sakila/test fixture corpus emits the expected Matches warnings (CreateFilmPayload, CreateFilmsPayload, CustomerAddressSummary, FilmLookupPayload, FilmReviewPayload, SetterShapeFilmReviewPayload) ; every @record-decorated reachable type whose directive’s className matches the producer’s return is now flagged as redundant. Transitional state (explicitly captured in the spec’s "Implementation notes" appendix before deletion, with the named follow-on tracked separately): walker’s @service result-axis observation is gated on the SDL return type carrying @record (preserves R75 single-record-carrier semantics; the post-retirement anchor is BuildContext.tryResolveSingleRecordCarrier, and the follow-on must flip the gate atomically with directive retirement); @tableMethod arm contributes input-axis observations only (the @table observation alone is sufficient for the result axis; obtaining the Table<Record> reflection class via TableImpl.recordType() for bare-class returns would add machinery without strengthening the diagnostic); buildResultType / buildNonTableInputType retain a directive-className fallback for types the walker can’t reach (so existing fixtures classify without mass migration); the rejection lands as a single Rejection.AuthorError.RecordBindingMultiProducer permit directly under AuthorError rather than a two-level RecordBindingMismatch.MultiProducer sub-taxonomy (the flat shape matches the rest of the AuthorError family and the typed List<ProducerBinding> payload already carries what a sub-arm would key off; the SealedHierarchyDocCoverageTest’s qualified-mention regex collides on two-level prefixes ; a known doc-coverage tooling fix tracked as a follow-on so future shapes that need sub-taxonomies aren’t blocked); drop-manifest golden file, validator-tier tests on the warning’s three variants, and the synthetic accessor-graph unit test on the resolver (diamond / deep chain / grounded cycle / ungrounded cycle) are deferred to the follow-on item, which will retire the directive-fallback path, migrate the remaining test fixtures, and ship the `directives.graphqls:290 directive declaration retirement atomically with the walker’s @record-gate flip once the warning count drops to zero across the corpus. Out of scope (per spec, not regressed): retiring the directive declaration itself; retiring any of the eight backed model variants (R96 changes the binding source, not the destination); the @service-payload error-construction surface (payloadFactoryLambda, ResultAssembly, PayloadAccessor); R94’s input-record validation seam (recordShape slot on the four InputType permits is graphitron-emitted validation class; R96’s recordBackingClasses is author-supplied accessor target ; orthogonal axes). Build green: full mvn -f graphitron-rewrite/pom.xml install -Plocal-db on Java 25.

  • R176 (8292468 preserve upstream rejection in EntityResolutionBuilder): replaces the misleading @key requires a @table-bound type; '<T>' has no @table directive synthesised at EntityResolutionBuilder.java:108-114 for any non-Table/Node classification with two call-site-specific behaviours that codify the principle downstream stages enrich an upstream rejection or pass it through; they do not relitigate it. When gType instanceof UnclassifiedType (the type was already rejected upstream by TypeBuilder.unknownTableRejection, the @node keyColumns unresolved-column check, malformed KjerneJooqGenerator node-id metadata, or @node declared on a type without implements Node), the new code skips the demote outright and lets GraphitronSchemaValidator.validateUnclassifiedType surface the original cause unmodified. When gType is a genuine non-table-bound classification surviving the assembledType instanceof GraphQLObjectType and TableInterfaceType pre-checks (today: PlainObjectType and the ResultType sub-hierarchy JavaRecordType / PojoResultType / JooqRecordType / JooqTableRecordType), the demote fires with a kind-aware rejection: @key on type '<T>' requires a table-bound type, but '<T>' is classified as <kind> ; federation entities need a @table directive. where <kind> is supplied by a new private kindLabel(GraphitronType) switch (PlainObjectType"a plain object type", the four ResultType variants → "a @record type", default branch → "a non-table-bound type" for any future classification a contributor adds without updating the switch). The three legitimate demote callers (TypeBuilder.java:226 typeId collision, EntityResolutionBuilder.java:104 @key on TableInterfaceType, EntityResolutionBuilder.java:128 alternative-build error) all demote from classified entries and stay as-is; only the bug call site is gated. Why call-site, not a TypeRegistry.demote-refuses-overwrite invariant: rejection durability is the caller’s responsibility because only the caller knows whether it’s enriching a rejection the classifier couldn’t see (legitimate) or relitigating one (the bug); the registry can’t tell those apart from the type signatures alone, so the discipline lives at the call site where the knowledge lives. Tests: EntityResolutionBuilderTest adds three regression cases pinning the new behaviour (keyOnTypeWithUnresolvableTable_preservesUnknownTableRejection asserts the rejection contains could not be resolved in the jOOQ catalog and does not contain has no @table directive; keyOnNodeTypeWithUnresolvableKeyColumn_preservesUnresolvedColumnRejection asserts the rejection contains key column 'definitely_not_a_column' in @node could not be resolved and the same absence; keyOnRecordType_namesRecordKindInMessage asserts the rejection contains is classified as a @record type and the same absence), and tightens the existing plainObjectTypeWithKey_demotesToUnclassifiedType from a loose contains("@table") to require both is classified as a plain object type and federation entities need a @table directive. Out of scope (called out, not regressed): surfacing all rejections per type rather than the first (the validator’s one-error-per-UnclassifiedType policy is unchanged); LSP fix-it hints for the new wording (the structural Rejection carries enough payload for an LSP layer to consume later); changing TypeRegistry.demote semantics. Build green: full mvn -f graphitron-rewrite/pom.xml install -Plocal-db on Java 25.

  • R94 (1224167 scope down emit shape to class-not-record (R172/R174 follow-ons filed), 418ef97 ship per-SDL-input-type validation class + rewired validator pre-step, e86c856 self-review cleanup: drop dead two-arg InputRecordGenerator.generate overload, simplify nested-input fromMap to one statement, refresh "Java record" → "Java class" Javadoc with R174 forward-looking notes): emits one graphitron-internal Java class per reachable SDL input type at <outputPackage>.inputs.<InputName> and rewires R12’s validator pre-step at TypeFetcherGenerator:1602+ to walk the typed instance instead of the raw Map. New HasInputRecordShape capability interface declared on the four InputType leaves (PojoInputType, JavaRecordInputType, JooqRecordInputType, JooqTableRecordInputType) and on TableInputType; InputRecordShape carries (recordClass, List<InputComponent>) with a compact constructor that rejects null/empty and backs the input-record.shape-from-input-type @LoadBearingClassifierCheck key. TypeBuilder.buildInputRecordShape walks SDL fields and lifts scalars via R101’s ScalarTypeResolver, enums to String (graphql-java’s wire shape), nested input refs to ClassName.get(<outputPackage>.inputs, name) (forward-declared ; javapoet does not require the class to exist at codegen, so mutually recursive inputs resolve cleanly), and lists to List<X>; a field whose scalar fails to classify routes the parent through UnclassifiedType via the existing fail-mode. InputRecordGenerator walks the reachable input closure off the assembled GraphQLSchema’s `GraphQLObjectType fields (the rewrite model’s RootType / TableBackedType don’t carry schemaType(), so the assembled schema is the authoritative source), expanding transitively through nested input components; each emitted class is public final with one private field per SDL component, a public same-name accessor, a private canonical constructor, a static fromMap(Map<String,Object>) factory (nested-input components recurse the sibling factory; list components stream element-wise; scalars/enums direct-cast; symmetric-null contract ; absent key and explicit null both collapse to a null component), and a per-class Javadoc tagging it as a graphitron-internal validation target. "inputs" is added to GraphQLRewriteGenerator.OWNED_SUBPACKAGES so the orphan sweep cleans regressions. TypeFetcherEmissionContext grows assembledSchema() + parentTypeName() so the rewired validator pre-step in TypeFetcherGenerator can resolve each SDL arg’s input-type-ness without re-walking the schema per arg; input-typed args materialise via <InputName>.fromMap(env.getArgument(name)) and feed validator.validate(<typed>). Scalar/enum args stay on the raw value path. Class-not-record by deliberate scope-down: graphitron-javapoet does not currently support TypeSpec.Kind.RECORD, sealed/permits clauses, or package-info.java emission (covers only CLASS, INTERFACE, ENUM, ANNOTATION); R174 (javapoet-record-sealed-package-info-support, Backlog) tracks the framework upgrade ; once it lands, InputRecordGenerator re-emits as actual records + sealed marker + package-info with no model-side ripple (InputRecordShape / InputRecordGenerator keep their names and semantics, only the rendered TypeSpec shape changes). The structural enforcement seam reduces to package boundary + per-class Javadoc for R94; R172 (inputs-package-internal-use-audit, Backlog) ships the service-side-reference audit independently. Hibernate Validator 9.0.1 walks records and beans identically for the property-path-from-component-name purpose R12’s ConstraintViolations.toGraphQLError needs, so the validator-walk function R94 delivers is preserved despite the source-form difference. Load-bearing pin: producer @LoadBearingClassifierCheck(key = "input-record.shape-from-input-type") on TypeBuilder.buildInputRecordShape pairs with @DependsOnClassifierCheck on InputRecordGenerator (per-input-type class emission) and TypeFetcherGenerator.validatorPreStep (typed-record materialisation in the pre-step rewire). Tests: pipeline-tier FetcherPipelineTest adds five R94 cases (inputRecord_scalar_emitsFromMapAndValidatesAgainstRecord, inputRecord_list_emitsListComponent, inputRecord_nested_recursesCoercer, inputRecord_unreachable_emitsNoRecord ; pins the reachable-closure scope decision via an unreachable input that produces no class ; and inputRecord_validatorPreStep_receivesTypedRecordNotMap ; the regression guard against drifting the pre-step back to validator.validate(Map)); unit-tier InputRecordGeneratorTest covers the emit shape (public class in <outputPackage>.inputs, fromMap factory with Map<String,Object> signature, one accessor per SDL component); InputTypeValidationTest gains a placeholderShape() helper since it constructs PojoInputType directly without the classifier. The validator pre-step walks the empty record (no constraints attached yet) ; the shape of the record (components, fromMap signature, walk-target) is exercised end-to-end on every fetcher with an input arg, so R98’s later content-attachment (programmatic ConstraintMapping entries) doesn’t have to reshape the record. R170 picks up the live invalid-input round-trip the moment R98 ships its first SDL constraint. Unblocks R98 (multi-source-input-validation, Backlog) ; the merged ConstraintSet’s programmatic-registration consumer gets its `mapping.type(InputRecord.class).field(componentName)…​ target ; and R170 (validator-integration-execute-coverage, Backlog) ; R12’s pre-step gains a real annotated walk target for the execute-tier ConstraintViolation round-trip fixture. Out of scope (called out, not regressed): exposing emitted classes to service signatures (R150 owns @service value flow via consumer-authored beans; the graphitron class is a validation target only and is discarded after validator.validate returns); replacing the Map.get() pattern in DML emitters (the four buildMutation{Delete,Insert,Update,Upsert}Fetcher paths and R75/R161-shipped DML-record paths keep their current shape ; the validate-only class runs in parallel at the fetcher boundary, value reads stay on the Map); destructuring @service callsites (R150 owns); service-side validator.validate calls (validation is a fetcher-boundary concern; the service never sees the graphitron class); designing the SDL validation directive set (R98 owns the curated @Range / @Size / etc.); narrowing/deprecating/removing @record on INPUT_OBJECT (R96 owns the reflection-derived backing-class binding; R94’s graphitron-emitted class at <outputPackage>.inputs.<InputName> lives at a separate Java identity from whatever class @record binds the input type to); retiring the four GraphitronType.InputType variants (R96 keeps the variants and reshapes how they’re populated); the @table + @record shadow rule at TypeBuilder:815-824 (untouched). Forward references: R164 (field-model-two-axis-pivot, Backlog) will repoint the validator-pre-step dispatch site into ValidationBuilder.OnInput-arm pattern matching once the field-model pivot lands; the substance of the pre-step (call <InputName>.fromMap, then validator.validate) is unchanged ; R94’s InputRecordShape / InputComponent are type-side (attached to GraphitronType.InputType), and ValidationBuilder is field-side (attached to Field), so the two axes don’t compete. R171 (input-like-type-sealed-parent, Backlog) tracks folding InputType ∪ TableInputType under a sealed InputLikeType parent so the capability declaration becomes one site instead of five; until R171 lands, a future sixth input-like variant added to GraphitronType.permits will not get a compile-time miss for HasInputRecordShape. Build green: full mvn -f graphitron-rewrite/pom.xml install -Plocal-db on Java 25 (1763 graphitron module tests + sakila compile-tier + execute-tier).

  • R9 (a4675bf Phase 1 pipeline, c38ea0f Phase 2 in-repo .md → .adoc, 562e732 Phase 3 alf/graphitron-landingsside absorption, aa3511e Phase 4 roadmap/by-theme/changelog/plans render, 7abec54 Phase 5a custom-domain cutover, b824207 In Review): graphitron.sikt.no is now built by Maven and deployed to GitHub Pages. /docs/ is a pom-packaged Maven module (graphitron-docs) wired into the rewrite reactor via <module>../docs</module>; download-maven-plugin fetches @sikt/sds-core and @sikt/sds-button from the npm registry tarball at pinned versions, maven-antrun-plugin flattens package/dist/index.css into target/staging/css/sds-{core,button}.css, maven-resources-plugin stages authored /docs/.adoc plus /graphitron-rewrite/docs/.adoc under architecture/, roadmap-tool render-adoc emits roadmap/{index,by-theme,changelog,plans/<slug>}.adoc, and asciidoctor-maven-plugin (in the default-on docs profile, opt-out via -P!docs) renders the merged tree to target/generated-docs/ with failIf severity=WARN so missing xrefs, missing includes, and unresolved attributes fail the build. CI splits across three workflows: rewrite-build.yml builds the rewrite reactor on PR and trunk push and adds trunk-only docs-build / docs-deploy jobs (actions/upload-pages-artifact + actions/deploy-pages@v4, concurrency: { group: pages, cancel-in-progress: false }); preview-docs.yml builds the docs site on every PR touching docs/, graphitron-rewrite/docs/, or graphitron-rewrite/roadmap/ and uploads the rendered tree as a workflow artifact; the legacy maven-build.yml stays unchanged on Java 21. Sikt Design System integration is build-time (pinned <sds.core.version> / <sds.button.version> properties, npm-registry tarball preferred over JSDelivr after the Claude Code Web sandbox returned 403 host_not_allowed), so the deployed Pages site has no runtime third-party dependency; sds-core’s `LICENSE.md is copied into target/staging/css/ for attribution, intra-Sikt scope on Sikt-owned domain authorises the redistribution. The custom-domain cutover (Phase 5a, 7abec54) dropped the /graphitron/ path-prefix Phase 1 anticipated; Sikt platform team handled DNS and the Pages-settings custom-domain config; no /docs/CNAME file shipped (custom domain set via Pages settings, the standard mechanism with actions/deploy-pages). Phase 5b (K8s deployment retire, GitLab CI pipeline retire, alf/graphitron-landingsside archive) landed external to this repo and is confirmed complete. *Done-commit housekeeping* (<this commit>): cleared four user-facing-doc-check leaks the independent In Review reviewer (session_011jbm5PpFDrqu3WjhtXDFB4) surfaced ; docs/index.adoc:69 dropped the R68 scaffold-only second sentence (the manual is shipped and populated), docs/manual/reference/directives/externalField.adoc:88 and docs/manual/how-to/computed-fields.adoc:152 reframed the computed-field-with-reference.md plan-slug references to feature-status notes, docs/manual/reference/directives/value.adoc:39 softened "deferred to a follow-up roadmap item" to "UPSERT generation is deferred". Pre-existing drift in CLAUDE.md:74 (Documentation site section) and docs/README.adoc:6 rewrote the stale .github/workflows/deploy-docs.yml reference to point at the docs-build / docs-deploy jobs in rewrite-build.yml plus preview-docs.yml for PR previews. Build green: mvn -f graphitron-rewrite/pom.xml -pl :graphitron-docs -am package renders the merged tree under the WARN-fails policy on Java 25.

  • R167 (ee06817 unify schema file extension handling between schemaInputs and graphitron:dev): centralises the "what counts as a schema file" decision onto a single <schemaFileExtensions> Mojo parameter and threads it through RewriteContext.schemaFileExtensions(): Set<String> to three consumer sites that previously drifted independently. SchemaInputExpander.expand post-filters scanner matches by extension; SchemaWatcher’s schema-mode constructor takes the `Set<String> instead of a hard-coded .graphqls; SchemaProblemDiagnostic.findOrphanSchemaFiles reads the configured set instead of its own hard-coded .graphql/.graphqls predicate. The seven- and six-arg RewriteContext overloads default the new field to Set.of(".graphqls", ".graphql") so unit-tier callers stay one-liners; AbstractRewriteMojo.effectiveSchemaFileExtensions() is the normalisation seam (trim, leading-dot prepended, duplicates collapsed, empty-after-normalisation rejected with a MojoExecutionException). The default matches the orphan scanner’s pre-R167 behaviour, so consumers with .graphql files (Opptak’s regelverkMutations_exp.graphql was the concrete pain point) get the graphitron:dev watcher firing on save and the <schemaInputs> glob-expansion picking the file up without configuring anything; teams reserving .graphql for client query documents opt in to the tighter <schemaFileExtensions><extension>.graphqls</extension></schemaFileExtensions> policy. SchemaWatcher’s single-string-suffix constructor stays for the `.class classpath watcher (DevMojo.startClasspathWatcher still passes ".class"). Tests: unit-tier SchemaFileExtensionsNormaliserTest (6 cases: null returns default, missing-dot prepended, duplicates collapsed, whitespace trimmed, all-blank rejected, explicitly-empty rejected); SchemaInputExpanderTest adds expand_filtersFilesNotMatchingConfiguredExtensions, expand_dotGraphqlAccepted, and expand_zeroMatchAfterExtensionFilter_throwsMojoExecutionException; SchemaWatcherTest adds dispatch_triggersOnDotGraphql_whenConfigured, dispatch_ignoresUnconfiguredSuffix, and constructor_emptySuffixSet_rejected; SchemaProblemDiagnosticTest adds findOrphanSchemaFiles_respectsConfiguredExtensions (tighten + loosen both checked). docs/manual/reference/mojo-configuration.adoc documents the new parameter in the per-parameter reference table and updates the dev goal description; the <schemaInput> row prose softens from .graphqls files to "schema files (or globs that expand to schema files)". Out of scope (called out, not regressed): dropping the trailing /*.graphqls off <pattern> declarations so patterns describe directories only (purely additive on existing patterns); case folding (Linux is case-sensitive, .GraphQLs stays as authored); bundled directive files (directives.graphqls is a classpath resource, not consumer-configured). Build green: full mvn -f graphitron-rewrite/pom.xml install -Plocal-db on Java 25.

  • R158 (f35644f admit @service-backed producers for single-record DML carrier data fields; da25606 pipeline + unit tests; 129909c execution-tier single-PK + ONE + empty + null-source cases; 08acc84 execution-tier MANY-arm composite-PK case): widens the single-record DML carrier’s data-field permit to admit a second producer kind alongside the existing INSERT/UPDATE/UPSERT DML mutation: an @service-backed mutation whose return type IS the carrier-payload’s typed XRecord / List<XRecord>. Closes the runtime ArrayList cannot be cast to org.jooq.Result reproducer (OpprettRegelverksamlingPayload + opprettRegelverksamling @service returning List<RegelverksamlingRecord>): pre-R158 the data-field fetcher cast env.getSource() to Result<RecordN<PK>> unconditionally, which holds for the DML mutation fetcher’s .returningResult(PK) shape but not for the developer’s verbatim list-of-typed-record return. SourceKey.Reader.ResultRowWalk’s compact-constructor invariant widens from `Wrap.Record + empty path to Wrap.Record OR Wrap.TableRecord(target.recordClass()) + empty path; the load-bearing key renames in lockstep to source-key.result-row-walk-target-aligned-empty-path (one @LoadBearingClassifierCheck on SourceKey and one @DependsOnClassifierCheck on FetcherEmitter updated, plus two javadoc references on ChildField.java / SourceKey.java). FetcherEmitter.buildSingleRecordTableFetcherValue becomes a sealed switch over SourceKey.Wrap permits: the Wrap.Record arm preserves the existing (Result<RecordN<…​>>) env.getSource() / (RecordN<…​>) env.getSource() casts and source.getValues(<PK>) / source.value1() reads unchanged; the new Wrap.TableRecord arm casts env.getSource() to (List<XRecord>) (MANY) or (XRecord) (ONE) and reads PKs through the typed record.get(<XTable.<PK_FIELD>>) accessors (single-PK uses the column’s Java type as map key; composite-PK uses List.of(r.get(pk1), r.get(pk2), …​) for map-keying and DSL.row(pk1, pk2).in(source.stream().map(r → DSL.row(…​)).toList()) for the response predicate); Wrap.Row is the unreachable arm pinned by an IllegalStateException. Registration moves to per-producer helpers. GraphitronSchemaBuilder.registerCarrierDataField’s `DataElement.Table arm hollows out; two new helpers in FieldBuilder are the only writers: registerDmlCarrierDataField (called from the non-DELETE DML kind classifier with Wrap.Record) and registerServiceCarrierDataField (called from the Resolved.Result arm of @service resolution with Wrap.TableRecord(target.recordClass())). The @service helper does its own strict method.returnType().equals(expectedReturnType) check against the carrier walk’s target.recordClass(), colocated because ServiceDirectiveResolver.computeExpectedServiceReturnType returns null for carrier-payload return types by design. Orphan carriers (a carrier type returned only from a Query field with no producing mutation) now land with no fieldRegistry entry, structurally safe under graphql-java’s never-traverse-an-unproduced-field guarantee. R156’s registerDeleteCarrierDataField Table arm passes null for expectedExistingClass in lockstep because the verbless walk no longer pre-registers; FieldRegistry.reclassify’s `expectedExistingClass parameter loosens to admit null (admits both no-prior-entry and matching-prior-entry once the helper-side compare-then-write has confirmed wrap agreement). Producer-kind monomorphism: a new BuildContext.carrierProducerRegistry map, keyed by (carrierType, dataFieldName) coords, records the first mutation that registers a SingleRecordTableField at each coord; the second producer’s helper reads it to enrich the rejection diagnostic when wrap shapes disagree (mixing a DML mutation and an @service mutation on the same carrier type rejects at classify time, naming both producer mutations regardless of registration order). The rejection routes through the standard UnclassifiedField + Rejection.structural + validateUnclassifiedField path; no parallel validator-mirror walk is needed. Load-bearing pins: producer @LoadBearingClassifierCheck(key = "carrier-data-field.single-producer-kind") on FieldBuilder.registerDmlCarrierDataField pairs with @DependsOnClassifierCheck on FetcherEmitter.buildSingleRecordTableFetcherValue (the wrap-permit dispatch relies on at most one wrap shape reaching the emitter per coord); producer @LoadBearingClassifierCheck(key = "carrier-data-field.service-producer-strict-return") on FieldBuilder.registerServiceCarrierDataField is a hygiene-rejection check (no consumer annotation owed; the cast safety it backstops is already pinned by source-key.result-row-walk-target-aligned-empty-path via the Wrap.TableRecord(target.recordClass()) invariant). Tests: unit-tier SourceKeyTest (six cases pinning the loosened Reader.ResultRowWalk compact-constructor invariant: Wrap.Record and Wrap.TableRecord(target.recordClass()) admit; cross-table Wrap.TableRecord(other) rejects with target-aligned message; non-empty path rejects under either admitted wrap; Wrap.Row rejects); pipeline-tier SingleRecordTableFieldServiceProducerPipelineTest (eight cases: ONE/single-PK, MANY/single-PK, and MANY/composite-PK FilmActor admission pins the Wrap.TableRecord(target.recordClass()) shape and registered SourceKey.columns; wrong-element-type, Set<XRecord>, Iterable<XRecord> reject through the strict-return predicate; mixed-producer DML-first and @service-first rejection asserts both producer mutation names and both wrap shapes appear in the diagnostic); execution-tier SingleRecordTableFieldServiceProducerExecutionTest against native PostgreSQL (five cases on Sakila FilmCarrierService / FilmActorCarrierService fixtures: MANY-arm single-PK input-order preservation through the R141 PK-keyed-map walk; MANY-arm composite-PK input-pair-order preservation exercising the typed row(pk1, pk2).in(…​) predicate emission and List.of(r.get(pk1), r.get(pk2)) map-key shape unique to multi-column keys; empty-source short-circuit; ONE-arm end-to-end; ONE-arm @service returns null → graphql-java does not traverse the carrier, payload renders null end-to-end); GraphitronSchemaBuilderTest’s `SINGLE_RECORD_CARRIER_DATA_FIELD repurposes as ORPHAN (Query-rooted carrier with no producing mutation: assert no fieldRegistry entry); SingleRecordCarrierPipelineTest.carrier_returnedFromQueryField* retargets to assert the orphan no-registration invariant. Out of scope (called out, not regressed): R141’s PK-keyed-map → VALUES-idx-JOIN migration (working code with its own audit surface; refactor of working code, not part of producer admission); Reader.ResultRowWalk consumed outside SingleRecordTableField (the widened invariant pairs only with the carrier data field’s permit today; any future consumer must adopt the same wrap-dispatch pattern or split its own permit); @service producer with DataElement.Record data field (identity-passthrough permit SingleRecordIdentityField is producer-kind-irrelevant because the data field’s value IS the parent’s, regardless of producer). Build green: full mvn -f graphitron-rewrite/pom.xml install -Plocal-db on Java 25.

  • R12 (carrier-walk LocalContext phases: eb590ef split ErrorChannel into sealed PayloadClass | LocalContext; c8731c9 add Transport discriminator on ChildField.ErrorsField; 676ff72 producer wiring for CarrierFieldRole.ErrorChannelRole; 04799b8 emit ErrorRouter.dispatchToLocalContext; d7d1c55 wire emit-time fork for ErrorChannel.LocalContext; 9cce63f select ErrorsField.Transport from parent’s resolved channel; a96766d register ErrorsField on carrier-walk carriers + MappingsConstantNameDedup arms; 093779c validator mirror for LocalContext errors-channel guard; f3ddcd4 thread non-null sentinel through LocalContext catch path + pipeline / execute tests; 93936d8 anchor null-source guard sites to validator allow-list via audit annotations; 4f1333f refresh spec body + split open execute-tier fixtures to R169 / R170): emit per-fetcher error channels from @error. Closes the long-standing "no generation (error mapping config)" gap so a payload’s errors: [SomeError!] field now routes a thrown exception into the typed payload instead of leaking the raw exception message through graphql-java’s default SimpleDataFetcherExceptionHandler. Foundational pieces landed first (sealed Handler taxonomy ExceptionHandler | SqlStateHandler | VendorCodeHandler | ValidationHandler on GraphitronType.ErrorType.Handler with parse-time lift and rules 1-6; ChildField.ErrorsField admitting where the five PolymorphicReturnType rejection arms used to fire; the ErrorChannel carrier with mappedErrorTypes, payloadClass, errorsSlot, defaultedSlots, mappingsConstantName; classify-time MappingsConstantNameDedup with 8-hex SHA-256 collision suffix; channel-level rule 7 multi-VALIDATION + rule 8 duplicate-criteria checks; ErrorRouter.dispatch / redact / Mapping taxonomy emitted at <outputPackage>.schema.ErrorRouter with ErrorMappings constants alongside; (List<String>, String)-ctor classifier check via Class.forName; per-@error-union/interface TypeResolver registration and per-@error-type path / message field DataFetchers in GraphitronSchemaClassGenerator; source-direct dispatch with no developer @error Java backing class; Optional<ErrorChannel> slot on every WithErrorChannel permit; per-fetcher try/catch wrapper + .exceptionally async tail routing the catch arm through ErrorRouter.dispatch or ErrorRouter.redact; DML payload assembly + dispatch; ResultAssembly for service-side payload assembly; rule 6 relaxation + per-(channel, @error type, handler) source-class accessor reflection check via R88’s ClassAccessorResolver; extensions.constraint field population on ConstraintViolations.toGraphQLError; child @service / @tableMethod errorChannel lift; @service / @tableMethod declared-exception channel-coverage check). The In Review pass shipped the carrier-walk LocalContext story (the R161 enabler): ErrorChannel is now a sealed PayloadClass | LocalContext interface with the channel-agnostic mappedErrorTypes() / mappingsConstantName() accessors; the BuildContext.classifyCarrierField producer admits errors-shaped wrappers as CarrierFieldRole.ErrorChannelRole with a LocalContext binding ahead of DataChannel resolution; ChildField.ErrorsField grows a Transport transport() component (PayloadAccessor | LocalContext) selected at classify time from the parent’s resolved channel; TypeFetcherGenerator.catchArm and asyncWrapTail switch exhaustively on the sealed root and emit ErrorRouter.dispatchToLocalContext(…​) for the LocalContext arm. The runtime fix threads a typed P sentinel through dispatchToLocalContext: graphql-java’s completeValueForObject short-circuits children on a null parent, so the catch arm now packs data(sentinel).localContext(List.of(t)).build() where sentinel = DSL.using(SQLDialect.DEFAULT).newRecord(<pk>) (single) / newResult(<pk>) (bulk); the data field’s null-source guard renders the SDL response as data: null while the errors field reads via env.getLocalContext(). The catchArm helper carries a generator-internal 3-arg overload that throws when a LocalContext callsite forgets the sentinel. Load-bearing pin: producer @LoadBearingClassifierCheck(key = "error-channel.local-context-transport") on BuildContext.classifyCarrierField pairs with @DependsOnClassifierCheck on TypeFetcherGenerator.dispatchToLocalContextCatchArm, the Transport.LocalContext arm of FetcherEmitter.dataFetcherValue, FieldBuilder.transportForParent, the validator mirror GraphitronSchemaValidator.validateLocalContextErrorsFieldGuards, and the four per-variant emitter sites (buildSingleRecordTableFetcherValueRecordWrap / …​TableRecordWrap, buildSingleRecordIdFromReturningFetcherValue, buildSingleRecordTableFromReturningFetcherValue, buildSingleRecordIdentityFetcherValue) that anchor each if (source == null) return null; guard to the audit harness. The validator mirror rejects schemas whose ErrorsField carries Transport.LocalContext but whose sibling data-channel field is outside the LOCAL_CONTEXT_GUARDED_DATA_CHANNEL_VARIANTS allow-list, turning a future widening that admits a non-guarded variant into a build-time Rejection.AuthorError.Structural rather than a request-time NPE. Tests: unit-tier ErrorRouterClassGeneratorTest (13 cases pinning dispatchToLocalContext(thrown, mappings, env, sentinel) signature + body packs sentinel into data()); pipeline-tier SingleRecordCarrierPipelineTest (34 cases including the three R12 + R161 integration tests: single-input MutationDmlRecordField and bulk-input MutationBulkDmlRecordField each classify with errorChannel = Optional.of(LocalContext) and a sibling ErrorsField with Transport.LocalContext; emit pin for SQLDialect.DEFAULT / newRecord sentinel construction and env.getLocalContext() reading); pipeline-tier ErrorChannelClassificationTest (21 cases including carrier-walk LocalContext admission + rule 7 multi-VALIDATION rejection through the unified walk); validator-tier LocalContextErrorsFieldValidationTest (3 cases: guarded sibling passes, unguarded sibling rejects with allow-list diagnostic, PayloadAccessor ErrorsField with unguarded sibling untouched); audit-tier LoadBearingGuaranteeAuditTest picks up the producer + all consumers via the annotation scan; execute-tier GraphQLQueryTest adds Sakila SDL fixtures FilmCreateLocalContextPayload + FilmCreateConstraintViolation @error type (handler GENERIC against org.jooq.exception.IntegrityConstraintViolationException) with two end-to-end paths: valid languageId=1 round-trips with {film: {…​}, errors: null}; languageId=99999 trips PostgreSQL FK 23503, routes through dispatchToLocalContext, renders {film: null, errors: [{__typename: FilmCreateConstraintViolation, path: […​], message: "…​foreign key…​"}]}. R2 retirement: checked-exceptions-typed-errors.md (Backlog R2) is subsumed by §4’s declared-checked-exception channel-coverage check and can be retired. Out of scope (called out, not regressed): execute-tier coverage for the @service ResultAssembly.Assembly arm (split to R169, not blocked); execute-tier coverage for the Jakarta ValidationHandler channel (split to R170, blocked on R94 emit-input-records); subscription error paths; batch-loader per-key error handling; federation entity-resolver errors; instrumentation hooks; transaction rollback semantics; consumer-facing ExceptionHandlingBuilder analogue (auto-wiring is the goal, no top-level handler to install); custom ExecutionStrategy for non-error reasons. Behaviour shifts vs legacy (documented in user-facing migration table): IllegalArgumentException messages are no longer automatically exposed to clients (schemas relying on the legacy auto-leak must declare {handler: GENERIC, className: "java.lang.IllegalArgumentException"}); DATABASE handlers now match any SQLException in the cause chain, not only those wrapped in Spring’s DataAccessException (non-Spring apps no longer need spring-jdbc for database error mapping). Build green: full mvn -f graphitron-rewrite/pom.xml install -Plocal-db on Java 25.

  • R161 (d0f676c retire DmlReturnExpression.Payload and unify @record-returning DML on the carrier-walk path; 8a9a707 lift the NoBacking/ClassBacked fork into SingleRecordCarrierResolution.Ok): collapses the two parallel @record-returning DML designs onto Path 1. BuildContext.tryResolveSingleRecordCarrier’s candidate predicate widens from `{PlainObjectType, PojoResultType.NoBacking} to {PlainObjectType, ResultType} so every @record(record:{className:}) wrapper (Backed, JavaRecordType, JooqRecordType, JooqTableRecordType) routes through MutationDmlRecordField / MutationBulkDmlRecordField uniformly; Mutation*TableField permits are now guaranteed never to carry a @record return, enforced structurally rather than via classifier-acceptance shape. The DmlReturnExpression sealed type collapses to four arms (EncodedSingle / EncodedList / ProjectedSingle / ProjectedList); the PayloadAssembly / RowSlot model types, the reflection-based resolver (resolveDmlPayloadAssembly, buildDmlPayloadAssemblyBeanArm, DmlPayloadAssemblyResult, NO_ASSEMBLY), and the emit layer (emitPayload, emitPayloadCtor, emitPayloadSetters) all retire. MutationInputResolver.validateReturnType drops the fqClassName == null guard on the ResultReturnType arm so the carrier-walk probe runs unconditionally ; one probe over the SDL shape, not two probes composing. The follow-up commit lifts the consumer-side parentType instanceof PojoResultType.NoBacking re-narrowing at GraphitronSchemaBuilder:227 into the model as a sealed Ok interface with NoBacking / ClassBacked record sub-arms (BuildContext.tryResolveSingleRecordCarrier tags the outcome via target instanceof ResultType && !(NoBacking)), per Generation-thinking: type-level classification short-circuits to carrier-walk registration only on NoBacking; ClassBacked falls through to normal per-type classification so R88’s per-field accessor-resolution diagnostics surface on developer-supplied classes, and the mutation classifier reclassifies the data field via compare-then-write at mutation time. code-generation-triggers.adoc’s Mutation Fields trigger table is qualified ("returning ID or a `@table type" on the four Mutation*TableField rows) and gains two new rows for MutationDmlRecordField / MutationBulkDmlRecordField; the .returningResult(pkCols) design decision is captured on buildMutationDmlRecordFetcher and buildMutationBulkDmlRecordFetcher (PK-only RETURNING keeps the write transaction minimal; data-field projection runs in a separate read-only follow-up SELECT outside the transaction). LoadBearingClassifierCheck descriptions on resolvePayloadConstructionShape and buildDmlField are trimmed to reflect the post-R161 consumer set (DML-row emitPayload removed; four-arm DmlReturnExpression). Tests: pipeline-tier DML_RECORD_PAYLOAD_RETURN_HAPPY and DML_RECORD_PAYLOAD_ROW_ONLY_HAPPY re-target as carrier-walk admission tests (DML_RECORD_CARRIER_WITH_ERRORS_HAPPY / DML_RECORD_CARRIER_ROW_ONLY_HAPPY) exercising the JavaRecordType wrapper arm admitted by the R161 widening; DML_RECORD_PAYLOAD_NO_ROW_SLOT_REJECTED repurposes as DML_RECORD_CARRIER_NO_DATA_CHANNEL_REJECTED (the carrier walk rejects the SDL shape for missing a DataChannel field rather than reflecting on the developer’s class); DML_RECORD_PAYLOAD_LIST_REJECTED keeps its name with the rejection diagnostic now coming from validateReturnType instead of resolveDmlPayloadAssembly. FetcherPipelineTest’s `dmlMutation_setterShapePayload_emitsSetterFactory, dmlDeleteField_recordPayloadReturn_successArmConstructsPayloadAndCatchArmDispatches, dmlDeleteField_recordPayloadReturnNoErrorsField_successArmConstructsPayloadCatchArmRedacts, and the unused SetterShapeDeleteFilmPayload fixture all delete (the bodies they asserted on no longer exist). After migration: zero references to DmlReturnExpression.Payload, PayloadAssembly, RowSlot, emitPayload*, or resolveDmlPayloadAssembly anywhere in the codebase. Out of scope (called out, not regressed): execution-tier coverage for the four ResultType className-carrying arms (sakila’s FilmPayload already pins the carrier-walk emit shape via NoBacking, and post-R161 every wrapper state runs the same emitter code, so a bespoke fixture would assert structural rather than behavioral coverage); consolidating MutationField permits under verb-on-permit-identity (MutationInsertResultField / etc., tracked at R162). Build green: full mvn -f graphitron-rewrite/pom.xml install -Plocal-db on Java 25.

  • R39 (f8fc604 wip validator + tests; d0cf6b5 validator ship + carrier permit lift; 2600955 lift PK-derived orderBy to OrderingOwnedByProducer sealed marker): Cross-cutting validator rejects SqlGeneratingField + FieldWrapper.List + OrderBySpec.None at build time, closing the silent-non-determinism gap on list fields targeting no-PK tables (OrderByResolver.resolveDefaultOrderSpec falls back to OrderBySpec.None when no @defaultOrder/@orderBy is present and the target has no PK, which generators faithfully emit as List.of() ; no ORDER BY ; producing visibly different row order each run). Three checks now cover three disjoint shapes: validatePaginationRequiresOrdering (paginated, including connections), validateSplitTableField connection branch (@splitQuery connections), validateListRequiresOrdering (plain [T] list wrapper). Gated on FieldWrapper.List not wrapper().isList() so the three messages stay non-overlapping; the sealed FieldWrapper permit list (Single/List/Connection) is the typed gate. Marker carrier: a new sealed OrderingOwnedByProducer (model package) permits ChildField.SingleRecordTableField and ChildField.ServiceTableField; the validator excludes marker-bearing permits by type. The carrier permit’s structurally-empty orderBy() = None stays ; the visible result order on these permits is owned by an upstream producer (FetcherEmitter’s PK-keyed-map walk for the R141/R158 carrier; the developer’s `@service method for ServiceTableField), not by the field’s own orderBy() component. An earlier shape derived a PK-fixed OrderBySpec.Fixed from sourceKey.columns() inside SingleRecordTableField.orderBy() to side-step the validator; principles-architect flagged this as duplicating OrderByResolver.resolveDefaultOrderSpec’s no-directive branch and coupling the validator’s correctness to a `FieldBuilder classifier guarantee that no involved file named. The marker refactor moves the exemption into the type system: find-usages from either permit lands on the validator’s exclusion site, and adding a new permit to the sealed marker is the explicit deliberation point. The check is hygiene-rejection (no emitter relies on it; TypeFetcherGenerator.buildOrderByCode / buildConnectionOrderingBlock / buildBaseReturnExpr and InlineTableFieldEmitter defensively handle None/empty Fixed for Single and Connection-without-pagination shapes the validator does not gate), so no @LoadBearingClassifierCheck/@DependsOnClassifierCheck pair is owed. Tests: unit-tier ListRequiresOrderingValidationTest (5 cases pinning Query-rooted + child-position dispatch path × list/single × ordered/unordered); pipeline-tier ValidateListRequiresOrderingPipelineTest (reject + admit through SDL → classified model → GraphitronSchema on the Sakila no-PK film_list fixture, asserting the contract’d error message); five pre-existing tests ({Lookup,QueryLookup,RecordTable,RecordLookupTable}FieldValidationTest) updated to use PK-fixed orderBy where they incidentally used OrderBySpec.None + FieldWrapper.List ; in every case the test’s subject is FK paths / projection / cardinality, not ordering. Marker-exemption coverage rides on the existing R141 / R158 execution-tier sakila tests (FilmsPayload, FilmsServicePayload, FilmActorsServicePayload): each declares a list-shaped data field with no @defaultOrder; the validator must admit them or the entire suite fails to build. Out of scope (called out, not regressed): requiring ordering on single-value fields (no-op); changing OrderByResolver to refuse OrderBySpec.None (the validator is the right layer for "legal in the model but illegal as authored schema"); merging the cross-cutting checks (remediation text deliberately differs). Build green: full mvn -f graphitron-rewrite/pom.xml install -Plocal-db on Java 25.

  • R165 (28cea70 gate empty fetcher-registration bodies at construction): FetcherRegistrationsEmitter.emit was producing empty-body entries for unreferenced payload-shaped types (the field-report reproducer was an unreferenced SlettRegelverksamlingPayload { regelverksamlingId: [ID!] @nodeId }), which surfaced as a javac error in consumer projects: GraphitronSchema.build() emitted <Name>Type.registerFetchers(codeRegistry) for every key in the bodies map, while ObjectTypeGenerator skipped the method emission when the body was empty. Fix gates emptiness at the construction site rather than via a post-pass scrub: typeBody and nestedBody return Optional<CodeBlock> (empty when the classified-field list is empty), and the two put call sites in emit use .ifPresent(body → result.put(name, body)). The dead fields.isEmpty() short-circuit inside buildBody is removed (typeBody guards before calling). ObjectTypeGenerator’s `fetcherBody != null && !fetcherBody.isEmpty() gate collapses to a null check (the !isEmpty() half is dead under the new invariant; the null half still guards types absent from the keyset). Load-bearing pair under key fetcher-registrations.no-empty-bodies pins the producer-side guarantee: @LoadBearingClassifierCheck on FetcherRegistrationsEmitter.emit, matching @DependsOnClassifierCheck on GraphitronSchemaClassGenerator.generate’s keyset iteration; `LoadBearingGuaranteeAuditTest catches future drift as an orphaned-consumer audit failure rather than a downstream javac error. Tests: unit-tier FetcherRegistrationsEmitterTest (post-condition that emit’s returned map has no empty `CodeBlock values, plus key-absence on the bug-reproducing payload fixture, across single-record carrier and connection/edge code paths); pipeline-tier FetcherRegistrationsPipelineTest asserts the bi-directional set-equality invariant between <Name>Type.registerFetchers(codeRegistry) call sites in the emitted GraphitronSchema.build() body and the type names whose ObjectTypeGenerator TypeSpec declares a registerFetchers method, on both the field-report fixture and a realistic mixed fixture (pinning both directions catches drift whichever side of the keyset/method contract moves). Out of scope (called out, not regressed): reachability-based pruning of unreferenced SDL types (the orphan payload survives via additionalTypes); strengthening emit’s return type to a `FetcherBodies record or Map<String, NonEmpty<CodeBlock>> carrier (R166 floats the broader GraphQLSchemaVisitor-driven emission rework where this would land). Build green: full mvn -f graphitron-rewrite/pom.xml install -Plocal-db on Java 25.

  • R154 (Phase 1 model lift + Phase 2 setter-shape admission, both landed before history-squash; landing SHAs unavailable post-squash): Admit a second @service payload construction shape ; public no-arg constructor + per-SDL-field Java-bean setters ; alongside today’s canonical all-fields constructor. New sealed PayloadConstructionShape { AllFieldsCtor | MutableBean } carries the contract; MutableBean.bindings holds one SetterBinding(sdlFieldName, setter, acceptsOptional) per SDL field in declaration order. The three carrier slot types lift in lockstep: ErrorsSlot, ResultSlot, RowSlot each become sealed with CtorParameterIndex(int) + SetterMethod(boundSetter, List<NonBoundSetter>) permits (kept as three sibling hierarchies, not folded onto one broad Slot interface, so ErrorChannel / ResultAssembly / PayloadAssembly keep role-specific access without instanceof widening). New NonBoundSetter(setter, defaultLiteral) record carries each non-bound SDL field’s setter paired with its language-default literal, so the catch-arm payload-factory emit walks one structured list and prints each setter call with its default value. Classifier: FieldBuilder.resolvePayloadConstructionShape(payloadCls, sdlFieldNames) returns a sealed PayloadConstructionShapeResult { Resolved(shape) | Reject(reason) }; predicates run in order with AllFieldsCtor first (canonical-over-bridge precedence: records always present the all-fields ctor; the setter shape is a legacy bridge from graphitron-codegen-parent); a class supporting both shapes resolves to AllFieldsCtor; the only rejection mode is neither-predicate-matches, with structured guidance enumerating the three escape hatches (convert to record / remove extra ctors / add no-arg + Java-bean setters). Three resolvers (resolveErrorChannel, resolveServiceResultAssembly, resolveDmlPayloadAssembly) consume the sealed shape and split into ctor-arm + bean-arm builders. Emit: TypeFetcherGenerator’s three payload-factory sites (catch-arm `errors → …​ lambda, service-result success arm, DML-row success arm) dispatch on PayloadConstructionShape via exhaustive sealed switch; the bean arm emits var p = new Payload(); p.setBound(…​); p.setOther(<default>); …​; return p; instead of new Payload(…​). Load-bearing pins: producer-side @LoadBearingClassifierCheck annotations on resolvePayloadConstructionShape for the keys payload-construction.shape-resolved (carrier-arm-totality) and payload-construction.setter-name-matches-sdl-field (setter.getName() is callable into the generated source); consumer-side @DependsOnClassifierCheck on the three emit-site forks plus the carrier helpers. Tests: unit-tier PayloadConstructionShapeTest (7 cases pinning record→AllFieldsCtor, bean→MutableBean, both-shapes→AllFieldsCtor canonical wins, missing-setter rejection naming the offending field, multi-ctor-no-no-arg→Reject, Optional<T> setter sets acceptsOptional, camelCase SDL field resolves through Java-bean naming xRating → setXRating); pipeline-tier FetcherPipelineTest four R154 cases (serviceMutation_setterShapePayload_emitsSetterFactory, _allFieldsCtorPayload_emitsCtorFactory_unchanged regression, _bothShapesPresent_prefersCtorFactory, dmlMutation_setterShapePayload_emitsSetterFactory); compilation-tier graphitron-sakila-service adds SetterShapeFilmReviewPayload (no-arg ctor + setReviewId / setErrors) plus FilmReviewService.submitSetterShape returning that type; sakila-example schema adds submitSetterShapeFilmReview mutation; execution-tier GraphQLQueryTest two end-to-end cases against real PostgreSQL (submitSetterShapeFilmReview_validInput_returnsHappyPathPayload and _invalidRating_routesThroughBadRatingErrorType) round-trip both the success-arm and the error-arm catch-arm payload-factory. Out of scope (called out, not regressed): builder-pattern (fluent immutable) payload classes (Payload.builder()…​.build()) ; a separate BuilderPattern permit on PayloadConstructionShape if a real schema surfaces it; replacing AllFieldsCtor with MutableBean (the two coexist; records remain the recommended shape); designing a @constructionShape(setter) SDL directive to disambiguate (per configuration drift reasoning, the structural signal the classifier already sees is sufficient); a deprecation diagnostic when a bean-shape payload could be a record (the parallel-support window has no deprecation pressure). Minor housekeeping noted at In Review → Done: SetterBinding.acceptsOptional is captured by the predicate but not consumed by any emit site (an Optional<T> setter receives null rather than Optional.empty() for default-slot inserts); the spec’s "parameter-type mismatch rejection" unit case is unimplemented (tryMutableBean accepts any single-arg setter on name match alone, the legacy convention’s de-facto behaviour). Build green: full mvn -f graphitron-rewrite/pom.xml install -Plocal-db on Java 25.

  • R156 (ba4697f Phase A model: DataElement.Id, PerFieldOutcome, PkResolution, two new ChildField siblings; cbe4634 Phase B verb-aware carrier walk + DELETE projection; 61ce2c8 Phase C/D FieldBuilder rewire + MutationField DELETE admission lift + FieldRegistry.reclassify; 8d88bb5 Phase E/F per-field emitters + DELETE DML chain emission; fe676bf Phase G partial L1/L3; a869716 Phase G L5 sakila fixtures; 160f102 Phase G L6 execution-tier coverage; e08c439 Phase H user docs; 424bc42 doc sweep; b5209a9 In Review rework ; PkResolutionEmitterReachabilityTest, MutationDmlNodeIdClassificationTest admission cells, MutationDeletePayloadCarrierCase rejection rows): @mutation(typeName: DELETE) now supports payload-returning carriers on two element arms, closing the gap that left DELETE the only DML verb without a payload-carrier story (a prerequisite for composing with R12’s errors: channel). New DataElement.Id(name, wrapper) arm admits an ID / [ID!] carrier field that echoes encoded NodeIds of deleted rows; admitted only on DELETE per the permit-verb rule (PK-echo commits to the PK as the entire post-image; INSERT/UPDATE/UPSERT post-images are richer). The existing DataElement.Table arm narrows on DELETE: a new builder-internal PerFieldOutcome (five arms: PkRead, NonPkNullable, NonPkNonNullable, ServiceField, UnsupportedField) classifies every field on the element SDL type, and BuildContext.classifyDeleteTableProjection either rejects (any non-PK-non-null / @service / FK-traversing / unsupported leaf, with a diagnostic naming the offending field) or projects to the narrow model-facing PkResolution (two arms) carried on a new ChildField.SingleRecordTableFieldFromReturning permit. The companion ChildField.SingleRecordIdFieldFromReturning carries the CallSiteCompaction.NodeIdEncodeKeys for the Id arm; both are siblings of the existing SingleRecordTableField and load-bearing for distinct invariants (no follow-up SELECT after DELETE, the row is gone). BuildContext gains a verb-aware tryResolveSingleRecordCarrier(typeName, DmlKind) overload that delegates to the verbless walk and layers DELETE-admissibility on top, so the unconditional DELETE-rejection at FieldBuilder.java:2960-2965 disappears entirely; MutationDmlRecordField / MutationBulkDmlRecordField compact constructors lift the DELETE rejection symmetrically. FieldRegistry.reclassify is the named exception that lets the DELETE carrier path replace the verbless walk’s SingleRecordTableField registration (which assumed follow-up SELECT) with the DELETE-specific sibling. FetcherEmitter adds two methods: buildSingleRecordIdFromReturningFetcherValue reads PK column(s) off the source Record and runs them through the encoder; buildSingleRecordTableFromReturningFetcherValue synthesizes a PK-only Record via Tables.<TABLE>.newRecord() and copies PK columns from the RETURNING source (the same-Field<T>-instance round-trip is the load-bearing assumption documented on the emitter and in the spec’s §Runtime caveats). The producer-consumer pin is @LoadBearingClassifierCheck(key = "mutation-delete-carrier.pk-resolution-projection-clean") on classifyDeleteTableProjection plus matching @DependsOnClassifierCheck on the table-arm emitter. Tests: unit-tier DataElementIdInvariantTest (6 cases pinning compact-constructor wrapper invariants, singleton ID/ID! and [ID!]/[ID!]! admit, list-of-nullable and Connection wrappers reject); unit-tier PkResolutionEmitterReachabilityTest (4 cases: reflective scan of PkResolution arms against FetcherEmitter’s `HANDLED_BY_EMITTER allowlist, PerFieldOutcome rejection arms exist and do NOT leak into PkResolution, record-component symmetry across the two sealed roots, @LoadBearingClassifierCheck pin reflectively confirmed on classifyDeleteTableProjection); pipeline-tier MutationDmlNodeIdClassificationTest gains six R156 rows (single/bulk × implicit/explicit @nodeId admission cells over nodeidfixture’s composite-PK `Bar and single-PK Baz, plus wrong-encoder-table and no-@node-backed-input-table rejection paths); pipeline-tier MutationDeletePayloadCarrierCase parameterised in GraphitronSchemaBuilderTest covers the admission/rejection matrix (nullable non-PK admits with PkResolution.NonPkNullable projection; non-null non-PK rejects naming the field and pointing at DataElement.Id; INSERT/UPDATE/UPSERT + [ID!] reject via permit-verb rule; [ID] list-of-nullable rejects at the verbless walk; @service-resolved element field rejects); compile-tier graphitron-sakila-example adds DeletedFilmsIdPayload, DeletedFilmsTablePayload, DeletedFilmInfo types + deleteFilmsIdCarrier / deleteFilmsTableCarrier mutations; execution-tier DmlBulkMutationsExecutionTest adds two end-to-end tests against real PostgreSQL proving the encoded-NodeIds list comes back in input order and the per-field PK projection through the synthesized Record resolves. Deviation from spec (called out in spec body): user docs landed at docs/manual/reference/directives/mutation.adoc rather than docs/manual/reference/mutations.adoc because the existing layout puts directive references under directives/. Out of scope (called out, not regressed): affected-row count payload field (separate Backlog item; structurally different role permit); error-channel composition (R12’s upstream producer; ErrorChannelRole permit already composes with the new arms); RETURNING * or projection-aware RETURNING for arbitrary non-PK columns (rejected in §Alternatives, the user’s rule narrows projections to PK only); dialect-capability gating on DELETE-RETURNING (existing dialect-roadmap item covers RETURNING capability checks); soft warnings on silent-null non-PK nullable fields (classifier stays binary; documented behaviour instead). Build green: full mvn -f graphitron-rewrite/pom.xml install -Plocal-db on Java 25.

  • R159 (6aababe admit + type-match + LSP arms; 8671b5b thread sourceSigil into DataChannel, route LSP through siteContext, drop back-compat constructors): root-value sigil $source on @field(name:) for carrier-payload sourcing. Authors now have an explicit, name-decoupled way to confirm the implicit binding between a @service-backed mutation’s reflected return and the SDL carrier-payload data field (the R158 contract). New FieldSourceSigil utility owns the sealed FieldNameRef = BareName | UpstreamRoot, ParseResult = Absent | Ok | UnknownSigil, and SiteContext = CarrierDataField | Other; the three canonical messages (unknown sigil, not-defined-here, type mismatch) live on the utility so classifier HardReject, LSP Diagnostics, and LSP FieldCompletions route through one source. BuildContext.classifyCarrierField interposes parseArgFieldNameRef before the forbidden-directive loop: UpstreamRoot lifts @field off the forbidden list for the iteration and threads sourceSigil = true into the emitted CarrierFieldRole.DataChannel(fieldName, element, sourceSigil); UnknownSigil HardRejects before the forbidden-directive loop fires (so the author sees "Unknown sigil" rather than "forbidden directive"). The type-match check runs at FieldBuilder.classifyMutationField’s `@service Resolved.Result arm (the colocation principle is preserved by the shared sourceSigilTypeMatches callable, with the bit-read replacing the SDL re-parse). LSP plumbing: CatalogBuilder.projectCarrierDataFields walks GraphitronSchema.fields() for ChildField.SingleRecord* permits to project Map<String, String> carrierDataFieldByType onto LspSchemaSnapshot.Built; Built.siteContext(typeName, fieldName) → FieldSourceSigil.SiteContext is the one entry point consumers use, so broadening admit in a future item flips a single sealed return-value. FieldCompletions admits $source at carrier-data-field sites and stays silent everywhere else (including snapshot-uncertainty: no entry in the carrier projection → no suggestion). Diagnostics.validateFieldMember emits sourceSigilNotDefinedHereMessage at non-carrier sites whose parent’s TypeBackingShape is known; snapshot-uncertainty stays silent (defers to the build). LspSchemaSnapshot.Built.Current / Built.Previous collapse to single canonical three-arg constructors; the two-arg back-compat overloads were a shim with no external producer to protect. Tests: pipeline-tier FieldSourceSigilPipelineTest covers admit, model-shape regression (with/without @field byte-identical), type-mismatch reject, unknown-sigil reject (parse-time arm fires before forbidden-directive HardReject), bare-name regression, non-carrier-site regression (today’s accessor-mismatch unchanged) ; each rejection case also asserts validator-surface ValidationReport.errors() via the same fixture; LSP-tier FieldCompletionsTest + DiagnosticsTest each gain three R159 cases (admitted / non-carrier / snapshot-uncertainty). Deviation from spec (called out in spec body): type-match site is FieldBuilder.classifyMutationField, not classifyCarrierField, because the producer’s MethodRef is bound at consumer-site classification not at the carrier walk; FieldSourceSigil.sourceSigilTypeMatches uses exact equality for both DataElement.Table and DataElement.Record today (spec called for assignability on the @record arm; the implementer’s note acknowledges "future items may relax when a forcing function appears"). Out of scope (called out, not regressed): admission at sites other than the carrier-payload data field (future broadening flips sourceSigilDefinedAt); $errors / $context / other sigils; dotted paths in @field(name:); multi-step path-expression grammar; DML-producer carrier walk migration to a $source model (R75 / R141 keep PK-keyed-map); execution-tier coverage of the OpprettRegelverksamlingPayload-shaped end-to-end gated on R158 landing the consumer-side fetcher. Build green: full mvn -f graphitron-rewrite/pom.xml install -Plocal-db on Java 25.

  • R139 (dev-pipeline to LSP schema-snapshot side-channel; first client unknown-directive validator): the bundled directives.graphqls was the LSP’s entire view of "what directives exist", so any user-authored directive (@key(fields:), @requiresAuthentication, @auth(role:), etc.) drew a Diagnostics warning per use as if it were a typo. Fix: a second volatile ref on Workspace (alongside catalog) carrying a projection of the parsed user schema, populated from the same parse the catalog already runs. New sealed LspSchemaSnapshot (Unavailable | Built.{Current,Previous}) over two orthogonal axes ; availability (built yet?) and freshness (latest successful parse?) ; lifts the Diagnostics.validateClassName-style "pre-build silence" gate from a single arm into a typed sub-hierarchy. Built carries List<DirectiveShape> with sibling InputValueShape + sealed TypeShape (Named | List) so phase-2 arg-validation consumers discriminate list-vs-named without re-parsing rendered SDL. Producer is CatalogBuilder.buildSnapshot(TypeDefinitionRegistry) (returns Built.Current only; failures throw upstream in GraphQLRewriteGenerator), pinned with @LoadBearingClassifierCheck on the keys snapshot-built-implies-clean-parse and snapshot-directive-roundtrip-faithful. Atomic-pair swap path is Workspace.setBuildOutput(…​) (absorbing the spec’s separately drafted setCatalogAndSnapshot and setCatalog-on-classpath setters, since the validator-report side-channel that landed on top of R139 wants the same atomic swap discipline); failure path is Workspace.demoteSnapshot() which transitions Built.Current → Built.Previous and is no-op on Unavailable / Previous. Resolution goes through sealed DirectiveResolution.resolve(LspVocabulary, LspSchemaSnapshot, String) returning Bundled | User | Unknown, encoding bundled-shadows-snapshot precedence once so consumers never re-check it inline; Workspace.resolveDirective(String) wraps the static entrypoint for request callbacks that already hold a Workspace. First client: the unknown-directive arm in Diagnostics.compute switches exhaustively on the snapshot variant for the freshness-aware silence policy ; warns only under Built.Current + Unknown, silences Unavailable (pre-build), Built.Previous (stale after parse failure), and any User / Bundled resolution. SPEC_BUILTIN_DIRECTIVES (skip, include, deprecated, specifiedBy, oneOf) keeps its short-circuit because graphql-java ships them implicitly. DevMojo.regenerate / rebuildCatalog both call setBuildOutput(…​) on success and demoteSnapshot() + markAllForRecalculation() on the parse-failure catch. Tests: unit-tier LspSchemaSnapshotTest (case-sensitive lookup across both Current / Previous, unmodifiable defensive copy at construction) and CatalogBuilderSnapshotTest (directive round-trip, list/non-null sealed projection, no producer-side bundled-name filter, description round-trip); pipeline-tier DiagnosticsTest grows unknownDirectiveSilencedByUnavailableSnapshot, unknownDirectiveSilencedByStaleSnapshot, userDeclaredDirectiveSilencedBySnapshot, userDeclaredDirectiveShadowedByBundledStillValidates, plus the existing unknownDirectiveProducesWarning updated to pass an explicit Built.Current(List.of(), Map.of()); compilation-tier sakila fixture declares directive @auth(role: String!) on FIELD_DEFINITION and applies it on Query.customers as the input-contract regression guard. Out of scope (called out, not regressed): hover / arg-completion / arg validation against user directives (later items; phase-2 arm validateUnknownArgsAgainstSnapshot / validateRequiredArgsAgainstSnapshot rides on the same plumbing); a declaredTypeNames set on Built (R157 widened the permits with typesByName for the analogous record/POJO use case); widening LoadBearingGuaranteeAuditTest across the graphitron / graphitron-lsp module boundary (consumer-side @DependsOnClassifierCheck markers on Diagnostics.compute and Workspace.resolveDirective are find-usages-only by design); a shadow-warning for user directives that redeclare bundled names; server-mode LSP without the dev mojo. Build green: full mvn -f graphitron-rewrite/pom.xml install -Plocal-db on Java 25.

  • R157 (6c33331 implement LSP @field(name:) coverage for @record-bound types; 6b9ea86 sealed-split JooqRecordBacking, drop back-compat shims, rename validator): @field(name: "X") autocomplete / diagnostics / hover only fired on jOOQ-table-bound parents; under @record-declared Java records, POJOs, and standalone jOOQ records the three consumer sites (FieldCompletions.generate, Diagnostics.validateCatalogColumn, Hovers.columnHover) silently returned empty because they parsed the enclosing type’s @table directive off the SDL AST and had no path to consult the classifier’s record/POJO knowledge. Fix: shift the LSP off SDL re-sniffing onto the classifier’s lifted model. New sealed TypeBackingShape (RecordBacking | PojoBacking | JooqRecordBacking{WithTable|Standalone} | TableBacking | NoBacking{Root|UnbackedResult|UnclassifiedInterface}) projects every GraphitronType permit to the LSP-visible backing shape; the projector lives in CatalogBuilder.projectType as an exhaustive sealed switch so a future GraphitronType variant trips a compile error at the projection site. LspSchemaSnapshot.Built.{Current,Previous} broaden to carry Map<String, TypeBackingShape> typesByName alongside the R139 directives list; GraphQLRewriteGenerator.buildOutput wires the three-arg buildSnapshot(registry, schema, catalog) form. ClasspathScanner reads the JVM Record attribute via java.lang.classfile.attribute.RecordAttribute so CompletionData.ExternalReference carries List<RecordComponent> per scanned class; the projector consumes these for RecordBacking and the bean-accessor filter (get<X> / is<X> no-arg public method) lives in CatalogBuilder.beanAccessorSlot for PojoBacking. The three consumer sites pattern-dispatch on the sealed permit (Diagnostics.validateCatalogColumn renamed to validateFieldMember, since it dispatches across four backing shapes plus NoBacking now); TypeContext.tableNameOf is gone from the three sites but stays for @nodeId(typeName:)’s metadata projection (R152 owns that migration). The `@LoadBearingClassifierCheck("java-record-type-backs-record-class") annotation on CatalogBuilder.buildSnapshot pins the assumption the three LSP-side @DependsOnClassifierCheck consumers make about JavaRecord{Input,}Type backing real Java record classes; the audit-test scope is graphitron-module-only by design (the LSP-side consumers wear the annotation for find-usages navigation and reviewer-signal purposes per rewrite-design-principles.adoc § "Classifier guarantees shape emitter assumptions"’s producer-without-consumer allowance). Tests: primary-tier `R157PipelineTest parses a realistic .graphqls, runs GraphitronSchemaBuilder for real, scans the LSP module’s target/test-classes for fixture classes (R157FilmRecord, R157FilmPojo), builds the full snapshot through CatalogBuilder, and drives FieldCompletions / Diagnostics end-to-end; unit-tier CatalogBuilderSnapshotTest adds per-variant projection cases (TableType, TableInterfaceType, JavaRecordType, PojoResultType.Backed, JooqTableRecordType, JooqRecordType-standalone, RootType, plain InterfaceType, PojoResultType.NoBacking); ClasspathScannerTest covers the Record-attribute read plus the plain-class empty case; FieldCompletionsTest, DiagnosticsTest, HoversTest adopt the snapshot-keyed dispatch and add positive cases per TypeBackingShape arm (RecordBacking → component list, PojoBacking → bean accessors, JooqRecordBacking.WithTable → column-on-table path, Standalone → silence, TableBacking → unchanged column-on-table path, NoBacking → silence). Out of scope (called out, not regressed): @enum(enum: {className:}) types (don’t carry @field(name:)); @reference(key:) on non-table backings (FKs are intrinsically a jOOQ-table concept; the directive stays on the existing TypeContext.tableNameOf path); union types as @field parents (meaningless directly; flows to NoBacking.UnbackedResult); migrating @nodeId(typeName:)’s metadata projection onto `typesByName (R152 owns the table-of-other-type scoping bug); per-component nullability / Jakarta-constraint surfacing on MemberSlot (R12-adjacent). Build green: full mvn -f graphitron-rewrite/pom.xml install -Plocal-db on Java 25.

  • R153 (555fa0f + 32c7ebe): Attach explicit TextEdit range to every LSP CompletionItem. Hoists the cursor walk + replace-range computation into LspVocabulary.locateAt (returns Optional<CursorLocation> carrying the schema coordinate plus the tree-sitter leaf node) and CompletionContext (carries the LSP Range derived by slicing the leaf: string_value strips one or three bytes per side, discriminated by content; enum_value / bare name use the full span); coordinateAt collapses to a thin wrapper preserving the Hovers caller. All six string-value providers (ClassName, Method, Table, Field, Reference, ScalarType) plus NodeTypeCompletions and ArgNameCompletions ship setTextEdit, so eglot’s graphql-mode syntax table (which excludes . as a symbol constituent) no longer concatenates the prefix with the candidate on dotted FQNs like com.example.FilmServ| becoming com.example.com.example.FilmService. Empty literals and block strings flow through the same slicing rule ("" and """""" collapse to zero-width at the inner cursor); ArgNameCompletions also fires on the arg-key side of an already-filled arg for partial-identifier completion. Coverage: LspVocabularyLocateAtTest (9 cases pinning node-kind dispatch plus empty-literal / block-string / empty-object-value corners) and CompletionTextEditTest (12 cases, one regression pin per provider plus cursor-on-quote / block-string / empty-literal / zero-width-on-whitespace). Out of scope (called out, not regressed): filterText for partial-match scoring and insertText snippet syntax; the user-directive arg-name path does not yet fire on the arg-key side of an already-filled arg (the bundled arm does; incidental asymmetry, would warrant its own roadmap item if a gap surfaces). Build green: full mvn -f graphitron-rewrite/pom.xml install -Plocal-db on Java 25.

  • R155 (0bd77de boundary box + invariant pin; fc55fa6 emitter-tier pin): graphitron:dev blew up with IllegalArgumentException: couldn’t make a guess for int on consumer schemas whose input beans carried a Java-primitive field (record int n, JavaBean void setActive(boolean), …) because java.lang.reflect.Type.getTypeName() returns the unboxed primitive literal and that string flowed unchanged through FieldBinding.javaElementTypeName into the two ClassName.bestGuess sites in InputBeanInstantiationEmitter (fieldLocalType, directExpr). Fix: normalise at the resolver boundary. New InputBeanResolver.boxPrimitive(String) maps each of the 8 primitive literals (int/long/boolean/double/float/short/byte/char) to its wrapper FQN (java.lang.Integer/…) and passes everything else through; peelJavaListSet calls it on the scalar return so FieldBinding.javaElementTypeName becomes invariantly a real class name, never a Java primitive literal. The list branches deliberately don’t box: Java disallows List<int>, so the generic argument is always already a reference type. Javadoc on the helper and a sentence on FieldBinding.javaElementTypeName pin the contract as prose; no @LoadBearingClassifierCheck annotations owed (this is representation-normalisation at the resolver, not a new classifier branch with downstream shape obligations). Tests: unit-tier InputBeanResolverBoxPrimitiveTest pins the full 8-arm primitive→wrapper mapping plus class-name pass-through plus int[] fallthrough; pipeline-tier GraphitronSchemaBuilderTest gains two cases (SERVICE_MUTATION_FIELD_INPUT_BEAN_PRIMITIVE_RECORD, SERVICE_MUTATION_FIELD_INPUT_JAVABEAN_PRIMITIVE_BOOLEAN) asserting FieldBinding.javaElementTypeName == "java.lang.Integer" / "java.lang.Boolean" on the record-component and JavaBean-setter paths respectively; TypeFetcherGeneratorTest gains two mirror cases pinning that InputBeanInstantiationEmitter.buildSingularHelper no longer throws on a boxed primitive FieldBinding and emits the wrapper-typed local and cast. Out of scope (called out, not regressed): generalising ClassName.bestGuess in graphitron-javapoet to accept primitives (javapoet treats primitives via TypeName.INT, a wider refactor of no immediate value); tightening the String-typed representation of FieldBinding.javaElementTypeName / EnumValueOf.enumClassName to a typed ClassName or sealed JavaTypeRef so the "real class name, never a primitive literal" invariant is a type fact rather than prose (separate Backlog item). Build green: full mvn -f graphitron-rewrite/pom.xml install -Plocal-db on Java 25.

  • R144 (db40644 ship cardinality safety default; a142f78 cover @value + @condition mutual-exclusion rejection; 11bd6ee drop unreachable admissibleCount == 0 branches): Flip the polarity of mutation-input safety. Every input field on a DELETE / UPDATE @table input is a WHERE filter by default; the classifier enforces that the contributed filter columns cover the input @table’s primary key. `@mutation(multiRow: true) opts out of the PK-coverage check, naming the hazard rather than the mechanism. @value on input fields marks UPDATE assignment columns; rejected on DELETE / INSERT / UPSERT and mutually exclusive with @condition on the same field. @lookupKey on INPUT_FIELD_DEFINITION is retired with a migration diagnostic surfaced at both per-field and per-arg classification sites (the ARGUMENT_DEFINITION use for Query-side LookupTableField / SplitLookupTableField is untouched). UPSERT is refused upstream at MutationInputResolver with a Rejection.deferred keyed to R145 (mutation-cardinality-safety-upsert); R141’s compact-constructor UPSERT rejection on MutationBulkDmlRecordField becomes a redundant type-system backstop during the R144-shipped-but-R145-not-yet window. Carrier change: TableInputArg.of now takes DmlKind kind and the @value-marked field-name set; partitions lookupKeyFields / setFields per verb (UPDATE: complement vs intersection on the @value set; DELETE / INSERT: setFields empty by classifier guarantee). EnumMappingResolver.buildLookupBindings drops the DIR_LOOKUP_KEY gate and walks every admissible input field minus a caller-supplied exclude set (the UPDATE @value names). Audit producers: two new @LoadBearingClassifierCheck keys on MutationInputResolver.resolveInput (mutation-input.where-columns-cover-pk, mutation-input.update-set-fields-equal-value-marked) with @DependsOnClassifierCheck consumers on the eleven tia.setFields() walk sites in TypeFetcherGenerator’s UPDATE arms and on the `MutationBulkDmlRecordField construction site (so any future refactor that branches the bulk path around resolveInput surfaces as an orphaned consumer in LoadBearingGuaranteeAuditTest). Migration: sakila example schema migrated (upsertFilm / upsertFilms / upsertFilmPayload retired; FilmUpdateInput.title / .description carry @value; new FilmReleaseYearDeleteInput + deleteFilmsByReleaseYear multiRow fixture); classifier truth-table fixtures retyped to the new diagnostics; UPSERT execution tests in DmlBulkMutationsExecutionTest, GraphQLQueryTest, and SingleRecordCarrierDmlTest @Disabled with R145 reference. Tests: pipeline-tier R144_* rows on GraphitronSchemaBuilderTest.MutationDmlCase (PK-coverage admission/rejection, multiRow admission, @value-on-DELETE rejection, multiRow-on-INSERT rejection, @value+@condition mutual-exclusion) plus UPDATE_NO_VALUE_FIELDS_REJECTED, UPDATE_EVERY_FIELD_VALUE_MARKED_REJECTED, UPDATE_PARTIAL_COMPOSITE_PK_REJECTED, DELETE_PARTIAL_COMPOSITE_PK_REJECTED, UPDATE_TIA_PARTITIONS_FIELDS_INTO_LOOKUP_AND_SET; execution-tier proof DmlBulkMutationsExecutionTest.deleteFilmsByReleaseYear_multiRowBroadcastsAcrossInputCardinality asserts |affected rows| == 3 while |input rows| == 1 against a release-year-keyed broadcast. The Spec’s "empty input + multiRow`" and "DELETE with zero admissible carriers" rejection bullets shipped as unreachable defensive checks (graphql-java rejects empty input types at parse with `"InputObjectType …​ must define one or more fields", and the per-field loop in resolveInput rejects every non-admissible field shape before the admissible-count check); both branches were removed per the project’s "no error handling for scenarios that can’t happen" rule with a Javadoc note on resolveInput recording the parser-level guarantee. Docs: new docs/manual/reference/directives/value.adoc covers the @value surface, per-verb validity rules, and the cardinality-safety interaction with multiRow; DirectiveDocCoverageTest green. Out of scope (filed as follow-ups): R145 (mutation-cardinality-safety-upsert) re-admits UPSERT with a designed cardinality story; R146 (mutation-cardinality-safety-unique-index) lifts the PK-only conservative cut to PK-or-unique-index coverage. Build green: full mvn -f graphitron-rewrite/pom.xml install -Plocal-db on Java 25.

  • R43 commit 5 (ChildField.RecordTableMethodField DTO-parent emit; lift to IMPLEMENTED_LEAVES; R43 In Progress → In Review): SplitRowsMethodEmitter.buildForRecordTableMethod emits the DataLoader rows-method for the new variant: parent VALUES table over the FK source-side columns + the developer’s static @tableMethod call substituted for the terminal Tables.<X>.as("alias") declaration + flat SELECT with JOIN parentInput ON terminal.<targetSide> = parentInput.<sourceSide> + scatter via scatterByIdx (list cardinality) or scatterSingleByIdx (single cardinality / LOAD_MANY). The new RowsMethodBody.SqlRecordTableMethod sealed permit gives the body the same framing as the existing SqlRecordTable* siblings (RowsMethodSkeleton emits the empty-input gate + DSL local; the permit’s content references both). TypeFetcherGenerator.buildRecordBasedDataFetcher’s generic constraint loosens from `<T extends TableTargetField & BatchKeyField> to <T extends GraphitronField & BatchKeyField> (taking returnType: ReturnTypeRef.TableBoundReturnType as a parameter) so the existing record-parent DataFetcher emit is shared across the three variants without an interface widening; RecordTableField / RecordLookupTableField call sites thread their returnType() explicitly. The scatterByIdx helper-emission gate gains a RecordTableMethodField-with-list-cardinality-non-single arm; the existing BatchKeyField.emitsSingleRecordPerKey gate already covers scatterSingleByIdx. Dispatch lift: RecordTableMethodField moves from STUBBED_VARIANTS to IMPLEMENTED_LEAVES; the deferred-slug entry retires; generateTypeSpec’s switch arm flips from `builder.addMethod(stub(f)) to the paired buildRecordBasedDataFetcher + SplitRowsMethodEmitter.buildForRecordTableMethod calls (mirroring RecordTableField). The variant overrides emitsSingleRecordPerKey() to fold single-cardinality fields onto the single-record-per-key arm, same shape as RecordTableField’s override. Path shape coverage: single-hop `JoinStep.FkJoin is the shipped emit form (the common case, and the only one exercised by the planned pipeline + execution coverage); multi-hop FK paths and JoinStep.ConditionJoin terminals surface a runtime UnsupportedOperationException with a labelled message, mirroring the table-parent TableMethodField commit-3 emit. Pipeline tests: TableMethodFieldPipelineTest.dtoParentFkAutoDerive_emitsDataLoaderFetcherAndRowsMethod pins the FilmRecord-backed @record parent + auto-FK-derive + explicit @reference path shape (the generated FilmDetailsFetchers.language DataFetcher signature is CompletableFuture<DataFetcherResult<Record>> wiring a DataLoader / rowsLanguage call; the rows method body invokes TestTableMethodStub.getLanguage, builds parentInput, and joins on LANGUAGE_ID). RowsMethodSkeletonTest.rowsMethodBody_sealedSwitchIsExhaustive count increments to six (the new permit), and UnifiedEmissionPinsTest.rowsMethodEmitter_unifiedSkeleton count increments to six (the new entry method emits two RowsMethodSkeleton.build calls: one for the emit-able single-hop FK arm, one for the multi-hop / ConditionJoin / empty-path runtime stub). Execution test: GraphQLQueryTest.filmById_detailsForMethod_languageViaTableMethod_routesThroughRecordTableMethodFieldDtoParentEmit exercises the end-to-end DTO-parent path against rewrite_test. Sakila fixture additions: a new SDL type FilmDetailsForMethod @record(record: FilmRecord) with filmId, languageId (declared so the parent SELECT projects film.language_id), and languageViaTableMethod: Language @tableMethod(…​) @reference(path: [{key: "film_language_id_fkey"}]); a new Film.detailsForMethod: FilmDetailsForMethod field that’s a ConstructorField passthrough so the parent Film row record flows through to FilmDetailsForMethod’s source-record slot. The query { filmById(film_id: ["1", "2"]) { filmId detailsForMethod { filmId languageId languageViaTableMethod { languageId name } } } } returns the seeded language_id=1 / name="English" for both films, confirming the developer’s tableMethodLanguage() table is correctly joined against the lifted FK keys. Out of scope (called out, not regressed): multi-hop FK path emit and ConditionJoin terminal emit for RecordTableMethodField (the runtime stubs are loud, not silent); execution-tier coverage for the @sourceRow arm (a separate fixture with a hand-written lifter is a candidate follow-up; the classifier branch is already covered by RecordTableMethodFieldCase). R43 moves In Progress → In Review with this commit. Build green: full mvn -f graphitron-rewrite/pom.xml install -Plocal-db on Java 25 (1683 graphitron tests + sakila-example compile + execute tiers all passing).

  • R43 commit 4 (ChildField.RecordTableMethodField variant added, emit stubbed): New sealed-permit on ChildField covers child @tableMethod fields on @record (non-table) parents ; the DTO-parent sibling of ChildField.TableMethodField. The record carries parentTypeName, name, location, ReturnTypeRef.TableBoundReturnType returnType, List<JoinStep> joinPath, MethodRef method, SourceKey sourceKey, LoaderRegistration loaderRegistration, Optional<ErrorChannel> errorChannel and implements ChildField, MethodBackedField, BatchKeyField, WithErrorChannel. It wears the shared @DependsOnClassifierCheck("tablemethod-resolver-return-is-table-bound") audit annotation (the resolver-side rejection of non-table returns underwrites the narrowed component type the same way it does for TableMethodField / QueryTableMethodTableField). Classifier: FieldBuilder.classifyChildFieldOnResultType grew a new @tableMethod branch placed before the @sourceRow branch, so both directives can coexist on the same field (their roles are complementary: @sourceRow provides the batch-key lifter; @tableMethod provides the developer’s static jOOQ table method). Two admit arms: (a) JooqTableRecordType parent + unique catalog FK between parent’s table and @tableMethod return-type table ; auto-derives the SourceKey via the existing deriveFkRecordParentSource helper, same Wrap.Row + Reader.ColumnRead shape RecordTableField’s FK arm produces; (b) free-form DTO parent (`PojoResultType / JavaRecordType) + @sourceRow(className:, method:) ; delegates to SourceRowDirectiveResolver for the lifter-derived SourceKey (Wrap.Record + Reader.SourceRowsCall). Both arms compose with @reference(path:) for explicit FK chains. The same last-hop-target check from the table-parent branch applies. A free-form DTO without @sourceRow and without FK metadata produces a structured UnclassifiedField AUTHOR_ERROR enumerating the three lift options (typed jOOQ TableRecord backing, @sourceRow, or a typed accessor). Dispatch / emit: STUBBED_VARIANTS gains an entry keyed on RecordTableMethodField.class with plan slug tablemethod-child-table-bound (commit 5 will lift it to IMPLEMENTED_LEAVES); generateTypeSpec’s child switch arm routes `RecordTableMethodField through stub(f) so schemas exercising the variant fail at validate-time with the standard deferred message rather than crashing at request time. Validator: GraphitronSchemaValidator gained a new switch arm + validateRecordTableMethodField helper applying the existing validateReferencePath + validateCardinality checks (mirror of validateTableMethodField for the table-parent sibling). Carrier-plumbing: MappingsConstantNameDedup.withResolvedChannel rebuilds the new variant via its existing WithErrorChannel sealed switch. Tests: new RecordTableMethodFieldCase enum in GraphitronSchemaBuilderTest pins three classifier shapes ; JOOQ_TABLE_RECORD_PARENT_AUTO_FK (FilmRecord parent + getInventory auto-FK to Inventory), JOOQ_TABLE_RECORD_PARENT_EXPLICIT_REFERENCE (FilmRecord parent + getLanguage with explicit @reference(path: [{key: "film_language_id_fkey"}])), and FREE_FORM_PARENT_NO_SOURCEROW_REJECTED (DummyRecord parent + getInventory without @sourceRow or FK metadata → rejection naming the three lift options). TestTableMethodStub gains getInventory() returning Inventory.class for the new tests. VariantCoverageTest confirms the new sealed leaf has classification coverage; the existing partition tests (GeneratorCoverageTest.everyGraphitronFieldLeafHasAKnownDispatchStatus and notImplementedReasonsContainsOnlyConcreteSealedLeaves) stay green because the new class is keyed in STUBBED_VARIANTS rather than IMPLEMENTED_LEAVES. Out of scope (kept under R43 commit 5): DTO-parent emit reusing RecordTableField’s DataLoader-keyed batch pattern with the developer’s static method substituted; pipeline-tier fetcher emission tests; execution-tier coverage against `rewrite_test; move RecordTableMethodField from STUBBED_VARIANTS to IMPLEMENTED_LEAVES. Build green: full mvn -f graphitron-rewrite/pom.xml install -Plocal-db on Java 25 (1673 graphitron tests passing).

  • R43 sub-commit (FK-projection injection for child @tableMethod on table-bound parents): Closes the execution-tier gap commit 3 left open. The child @tableMethod fetcher reads parentRecord.get(DSL.name("<sourceSqlName>"), …) for parent-row correlation; without injecting the FK source-side column into the parent SELECT, the read throws IllegalArgumentException: Field "<col>" is not contained in row type ("<schema>"."<parentTable>"."<pk>") whenever the user’s SDL selection omits the FK column. Fix: TypeClassGenerator.collectSourceKeyColumns generalises to collectRequiredProjectionColumns and gains a ChildField.TableMethodField arm that extracts the single-hop JoinStep.FkJoin’s `sourceSideColumns() and threads them through the existing requiredProjectionColumns pipeline ; same if (!fields.contains(table.$L)) fields.add(table.$L) idempotent-append idiom Split* fields already use for their SourceKey columns. Only single-hop FkJoin shapes contribute: multi-hop and ConditionJoin paths surface a runtime UnsupportedOperationException in buildChildTableMethodFetcher anyway, so projecting their first hop would synthesise dead columns. NestingField recursion preserved so nested @tableMethod fields under a non-table-bound nested type get their FK columns into the outer table-class’s $fields. Pipeline tests: TableMethodFieldPipelineTest gains singleFkAutoInferred_parentDollarFieldsProjectsFkSourceColumn (auto-FK Inventory→Film pins FILM_ID on Inventory.$fields) and explicitReferencePathSingleHopFk_parentDollarFieldsProjectsFkSourceColumn (explicit @reference(path: [{key: "film_language_id_fkey"}]) pins LANGUAGE_ID on Film.$fields); both use the shared TypeSpecAssertions.appendsRequiredColumn helper that already pins the same idiom for Split* fields. Execution tests: GraphQLQueryTest gains inventoryById_filmViaTableMethod_correlatesParentRowViaInjectedFkProjection (three Inventory rows each correlating to their matching Film by inventory.film_id; asserts film.filmId and film.title per row) and filmById_languageViaTableMethod_correlatesParentRowViaExplicitReferencePathFk (two Film rows each correlating to Language via film.language_id; asserts language.languageId == 1 and stripped language.name == "English"). Both queries deliberately omit the FK column from their SDL selection so the projection-injection path is exercised end-to-end. Out of scope (kept under remaining R43 commits): the new ChildField.RecordTableMethodField variant for DTO-parent batching (commit 4); DTO-parent emit + execution coverage (commit 5). Build green: full mvn -f graphitron-rewrite/pom.xml install -Plocal-db on Java 25.

  • R43 commit 3 (child table-bound-parent lift: ChildField.TableMethodField moves to IMPLEMENTED_LEAVES): New TypeFetcherGenerator.buildChildTableMethodFetcher emits a per-row fetcher modelled on the root-site buildQueryTableMethodFetcher: declare parentRecord = (Record) env.getSource(), call the developer’s static @tableMethod to derive the target table local, declare dsl, build a parent-correlation Condition from the resolved JoinStep chain, then SELECT the typed $fields projection from the developer-returned table with the correlation as WHERE. The new helper buildTableMethodParentCorrelation walks each JoinSlot of the (single) FkJoin hop and emits table.<targetSide>.eq(parentRecord.get(DSL.name("<sourceSqlName>"), <columnClass>.class)), threading the typed parentRecord.get(name, Class) overload so the resulting Condition type-checks against Field<T>.eq(T). Composite FKs AND across slots; an empty-slots fallback emits DSL.noCondition() so the catalog-unavailable case fails loudly at runtime. Path shape coverage: single-hop JoinStep.FkJoin is the shipped emit form (the common case, and the one exercised by R43’s planned pipeline + execution coverage). Multi-hop FK paths and JoinStep.ConditionJoin terminals are still accepted by the classifier (GraphitronSchemaBuilderTest.TableMethodFieldCase.LIST_RETURN / CONNECTION_RETURN / WITH_CONDITION_PATH) but the emitter surfaces a runtime UnsupportedOperationException with the shape label ("empty joinPath" / "multi-hop join path" / "ConditionJoin path") so the gap is loud rather than silent. Three @DependsOnClassifierCheck annotations pin the producer/consumer contracts (tablemethod-resolver-return-is-table-bound for the narrowed return type, service-catalog-strict-tablemethod-return for the no-downcast emit, service-catalog-tablemethod-must-be-static for the static-call shape) plus fk-join.slots-oriented-source-and-target on buildTableMethodParentCorrelation. Dispatch: IMPLEMENTED_LEAVES gains ChildField.TableMethodField.class; STUBBED_VARIANTS loses its entry (the tablemethod-child-table-bound plan-slug binding retires); generateTypeSpec’s child switch arm flips from `builder.addMethod(stub(f)) to builder.addMethod(buildChildTableMethodFetcher(ctx, f, outputPackage)). Pipeline tests: new TableMethodFieldPipelineTest two cases: singleFkAutoInferred_emitsFetcherMethod (Inventory.film with single-FK auto-inference) and explicitReferencePathSingleHopFk_emitsFetcherMethod (Film.language with @reference(path: [{key: "film_language_id_fkey"}])); both assert the generated FilmFetchers / InventoryFetchers contain a fetcher method under the field name with the standard (DataFetchingEnvironment) signature, the correct DataFetcherResult<Record> return type, and a body that invokes the developer-authored static method. TableMethodFieldValidationTest’s three `stubbedError cases (NO_PATH, WITH_FK_PATH, WITH_CONDITION_ONLY) flip to assert empty errors ; TableMethodField is no longer in STUBBED_VARIANTS, so GraphitronSchemaValidator.validateVariantIsImplemented is silent for these fixtures. Compile-tier coverage: sakila-example schema gains Inventory.filmViaTableMethod: Film @tableMethod(…​) (auto-FK single-hop) and Film.languageViaTableMethod: Language @tableMethod(…​) @reference(path: [{key: "film_language_id_fkey"}]) (explicit single-hop path); SampleQueryService adds tableMethodFilm() / tableMethodLanguage() returning Tables.FILM / Tables.LANGUAGE directly; the graphitron-sakila-example compile step type-checks the generated fetcher bodies against the real jOOQ classes (FilmFetchers.languageViaTableMethod and InventoryFetchers.filmViaTableMethod). Out of scope (deferred to a follow-up): runtime execution-tier coverage requires the parent fetcher to project the FK source column (e.g. inventory.film_id) when the child @tableMethod field is in the selection set; today the parent’s $fields walks only user-requested SDL fields and parentRecord.get(DSL.name("film_id"), …) fails with IllegalArgumentException: Field "film_id" is not contained in row type ("public"."inventory"."inventory_id"). Mechanism for FK-column injection (analogous to the projection synthesis that NodeIdReference / CompositeColumnReference already do at classify time) is a separable concern from the lift itself; commit 3 ships the emit + dispatch lift, and a follow-up R43 sub-commit will land FK-projection injection so the sakila fixture’s runtime path comes online. Out of scope (kept under remaining R43 commits): multi-hop FK path emit; ConditionJoin emit; the new ChildField.RecordTableMethodField variant for DTO-parent batching (commit 4); DTO-parent emit (commit 5). Build green: full mvn -f graphitron-rewrite/pom.xml install -Plocal-db on Java 25 (1640+ graphitron tests + sakila-example compile + execute tiers all passing).

  • R43 commit 2 (path resolution + last-hop-target validation for @tableMethod at child sites): FieldBuilder.classifyChildFieldOnTableType’s `@tableMethod arm reorders the resolver and parsePath calls so the return-type table is known before path resolution: tableMethodResolver.resolve runs first, then ctx.parsePath(fieldDef, name, tableType.table().tableName(), tb.returnType().table().tableName(), buildWrapper(fieldDef).isList()) runs with the target table populated, exercising the existing auto-FK inference branch in parsePath (findForeignKeysBetweenTables with directiveAbsent=true). The classifier adds a last-hop-target check: if the resolved path is non-empty and its last hop is a JoinStep.FkJoin, the hop’s targetTable().tableName() must equal the return-type’s table name (case-insensitive), else surface "@tableMethod @reference path: last hop lands on '<X>' but @tableMethod’s return type is bound to table '<Y>'". JoinStep.ConditionJoin last hops are exempted from the structural check by design ; the condition method’s signature is the implicit contract there. Three accepted shapes (matching @reference semantics): (a) no @reference + exactly one FK between parent and return-type tables → single-hop FkJoin auto-inferred; (b) explicit @reference(path: [{key: "…​"}, …​]) → walks each hop, last hop must land on return-type table; (c) @reference(path: [{condition: {className, method}}]) → ConditionJoin terminal. Three rejection shapes: ambiguous FK (multiple FKs between parent and target, no @reference) surfaces the existing fkCountMessage "multiple foreign keys found between tables …" with directive-absent guidance; missing FK + no @reference surfaces fkCountMessage’s zero-FK arm; last-hop-target mismatch surfaces the new structural rejection. Test fixtures updated for the new behaviour: `GraphitronSchemaBuilderTest.TableMethodFieldCase.SINGLE_RETURN / LIST_RETURN / CONNECTION_RETURN / TABLE_METHOD_FIELD_CONTEXT_ARGS add explicit @reference(path: …) (Film→Language had two FKs and would now reject as ambiguous; Film→Actor has no direct FK and would now reject as missing). New pipeline tests: TableMethodFieldCase.WITH_AUTO_FK_INFERENCE (Inventory→Film single-FK auto-infers a single-hop FkJoin landing on film); TableMethodFieldCase.WITH_CONDITION_PATH (@reference(path:[{condition:…}]) resolves to ConditionJoin). New rejection tests in UnclassifiedFieldCase: TABLEMETHOD_CHILD_AMBIGUOUS_FK_REJECTED (Film→Language with no @reference); TABLEMETHOD_CHILD_MISSING_FK_REJECTED (Film→Actor with no @reference); TABLEMETHOD_CHILD_LAST_HOP_MISMATCH_REJECTED (@reference(path:[{key:"film_language_id_fkey"}]) on a field declared Actor → last-hop-lands-on-language rejection). No emit change: TypeFetcherGenerator.STUBBED_VARIANTS still maps ChildField.TableMethodField.class to the deferred slug; the dispatch in generateChildFetcher still routes to stub(f). The lift to IMPLEMENTED_LEAVES is R43 commit 3. Out of scope: emit (commit 3); the new ChildField.RecordTableMethodField variant for DTO-parent batching (commit 4); DTO-parent emit (commit 5). Build green: full mvn -f graphitron-rewrite/pom.xml install -Plocal-db on Java 25.

  • R43 commit 1 (directive flattening + method-signature rewrite at the root site): Retired BuildContext.ARG_TABLE_METHOD_REF; flattened the @tableMethod directive in directives.graphqls from (tableMethodReference: ExternalCodeReference!, contextArguments:) to (className: String!, method: String!, argMapping: String, contextArguments: [String!]), mirroring @sourceRow. TableMethodDirectiveResolver.resolve parses the flat args inline (sibling to SourceRowDirectiveResolver); parseExternalRef stays as-is for @service / @externalField. Method-signature contract: developer’s @tableMethod static method now receives only GraphQL field arguments and contextArguments: values ; no Table parameter. ServiceCatalog.reflectTableMethod gains a new TableSlotPolicy { REQUIRED, FORBIDDEN } parameter so the three call sites (TableMethodDirectiveResolver, two @condition paths in ConditionResolver, two @condition paths in BuildContext.parseCondition*) pick their semantics: @tableMethod passes FORBIDDEN (reject any Table<?> parameter; no foundTable requirement), @condition callers pass REQUIRED (keep the original ParamSource.Table slot + foundTable invariant + the reserved-Table-slot argMapping typo guard, factored as the new checkConditionOverrideTargets helper). TypeFetcherGenerator.buildQueryTableMethodFetcher now passes null for tableExpression to ArgCallEmitter.buildMethodBackedCallArgs and the emitted call drops the leading Tables.<NAME> argument; the body shape stays var table = ClassName.method(<args>) with <args> now sourced exclusively from ParamSource.Arg / ParamSource.Context slots. The flat form drops the deprecated name: alias on @tableMethod (the existing @sourceRow precedent already shipped without it). LSP canonical overlay (LspVocabulary.CanonicalOverlay) gains three bindings paralleling @sourceRow: tableMethod.className → ClassNameBinding, tableMethod.method → MethodNameBinding(tableMethod.className), tableMethod.argMapping → ArgMappingBinding. Test-fixture migration: every @tableMethod(tableMethodReference: {className: "X", method: "Y"}) occurrence in GraphitronSchemaBuilderTest, ServiceRootFetcherPipelineTest, TableMethodFieldValidationTest, DiagnosticsTest, ClassNameCompletionsTest, and the sakila-example schema flattens to @tableMethod(className: "X", method: "Y"). TestTableMethodStub static methods drop their leading Table<?> parameter. SampleQueryService.popularFilms rewrites from (Film filmTable, Double minRentalRate) to (Double minRentalRate), deriving Tables.FILM internally. ServiceCatalogTest’s `reflectTableMethod_* cases thread the new policy parameter; reflectTableMethod_overrideTargetingTableSlot_rejected now exercises the REQUIRED policy via TestConditionStub.argCondition. TypeFetcherGeneratorTest.queryTableMethodTableField_emittedFetcher_* drops the ParamSource.Table slot from its handcrafted MethodRef. The LSP legacyName_unresolved_tableMethod test retires (name: alias is gone). User-facing doc docs/manual/reference/directives/tableMethod.adoc rewrites the SDL signature and examples to the flat form and adds a "Method-signature contract" section pinning the no-Table-parameter rule. docs/manual/how-to/external-code.adoc updates the per-directive slot-name table to distinguish flat-form (@tableMethod, @sourceRow) from ExternalCodeReference-shaped directives. R43 status was moved Ready → In Progress in 55b5d5f ahead of this commit. Out of scope (kept under remaining R43 commits): path resolution + last-hop-target validation; child table-bound-parent lift (TypeFetcherGenerator.STUBBED_VARIANTS membership for ChildField.TableMethodField remains); new ChildField.RecordTableMethodField variant. Build green: full mvn -f graphitron-rewrite/pom.xml install -Plocal-db on Java 25.

  • R100 (362719e implementation; 8498b89 In Progress → In Review): LSP coverage for @node(keyColumns:) and @nodeId(typeName:). Per-keystroke responsiveness layer on top of the existing classifier-rejection paint (validatorDiagnostics keeps full coverage at the rebuild tier). Two canonical-overlay deltas plus one new Behavior arm: @node(keyColumns:) → CatalogColumnBinding so FieldCompletions and Diagnostics.validateCatalogColumn auto-fire over the type’s @table-backed jOOQ column list; @nodeId(typeName:) → new Behavior.NodeTypeBinding() siblings-by-keyset to CatalogColumnBinding / CatalogTableBinding, with one new completion provider (NodeTypeCompletions) and one new arm in Diagnostics.dispatch that reads a new CompletionData.nodeMetadata() map. CatalogBuilder walks the assembled schema’s GraphQLObjectType`s and records pre-deduction `(typeId, keyColumns) per @node-bearing type; classifier-deduced values (containing-type / unique-table / PK inference) stay invisible to in-editor feedback by design. LspVocabulary.leafCoordinates / descendLeaves fan out raw list_value AST nodes into one Leaf per scalar element so CatalogColumnBinding dispatches per-element on keyColumns: […​]; the contract pin “Leaf.valueNode` is the scalar value node, never an enclosing list_value” lifts to a universal property of the leaf walk. `Hovers.valueNodeFor mirrors the descent so cursor inside a list element highlights the element, not the whole list; new nodeTypeHover arm renders the target type’s typeId + key-column list with each column’s graphqlType pulled from CompletionData.Column. Two backwards-compat CompletionData constructors retained (existing 3-arg pattern extended with a 4-arg shim for tests not carrying the new map). Tests five wire-shape integration cases (FieldCompletionsTest.nodeKeyColumnsCompletionInsideListLiteralReturnsTableColumns; DiagnosticsTest.nodeKeyColumns_unknownElement_producesError / _allValid_producesNoError, .nodeIdTypeName_unknownType_producesError / _knownNodeType_producesNoError / _emptyNodeMetadata_suppressesUnknownTypeDiagnostic; HoversTest.nodeKeyColumnsHover_insideListElement_showsColumnMetadata / .nodeIdTypeNameHover_resolvesTypeIdAndKeyColumns); per-provider unit cases in NodeTypeCompletionsTest (3); leaf-walk fan-out pin in LspVocabularyTest.leafCoordinates_listValueFansOutOneLeafPerElement; catalog-side NodeMetadata coverage in CatalogBuilderTest (3 cases ; author-supplied capture, omitted-axes-stay-null, non-@node types omitted). Reference docs gain "Editor support" subsections on node.adoc and nodeId.adoc. Out of scope (called out, not regressed): @node(typeId:) cross-schema duplicate validation stays on rebuild path; @nodeId(typeName:) deduction rules (containing-type / unique-table inference) stay invisible to LSP; @node placement and PK-defaulting structural validation stays on the rebuild path; @nodeId(typeName:) diagnostic conflates "type doesn’t exist" and "type exists without @node`" into one message and tests only the unknown-type case (the spec listed both scenarios; the type-exists-without-@node` case is incremental coverage of the same code path and a candidate follow-up); nodeTypeHover.columnGraphqlType does case-insensitive across all tables rather than scoping to the @node type’s @table (latent, not triggered by Sakila; candidate follow-up). Build green: full mvn -f graphitron-rewrite/pom.xml install -Plocal-db on Java 25.

  • R141 (d58d46c In Progress → In Review carrier-shape lift + new sealed leaf + tests; 83cd67a rework: order-preservation PK-keyed-map indirection in FetcherEmitter.buildSingleRecordTableFetcherValue’s `Cardinality.MANY arm): Admit bulk-input mutations with a single payload carrier wrapping a list-shaped data field. New MutationField.MutationBulkDmlRecordField sealed leaf (sibling of MutationDmlRecordField) classifies (tia.list() == true, dataField.wrapper().isList() == true, kind ∈ {INSERT, UPDATE}); carrier-shape lift introduces sealed CarrierFieldRole (permits DataChannel, ErrorChannelRole) with SingleRecordCarrierShape carrying List<CarrierFieldRole> under compact-ctor invariants (exactly-one DataChannel, at-most-one ErrorChannelRole, distinct field names), consolidating the previously parallel data + error walks into a single unified walk in BuildContext.tryResolveSingleRecordCarrier (the carrier-side resolveErrorChannel call site retires; the standalone method stays for the four non-carrier callers). Compact-ctor on the new leaf rejects DELETE (incorrect-by-construction) and UPSERT (deferred to R145 under R144’s cardinality-safety regime); UPSERT bulk-carrier case surfaces as a classify-time author-facing rejection rather than letting the compact-ctor throw. Single-input + list-data-field rejects as new Invariant #16 inside MutationInputResolver.validateReturnType. Emit strategy: per-row DML inside dsl.transactionResult(…​) accumulating PKs into Result<RecordN<PK>> in input order (N+1 statements: N per-row DML + 1 response SELECT); order preservation lifted from a Postgres-scan-order coincidence to a property of the emitted Java via PK-keyed-map indirection in FetcherEmitter.buildSingleRecordTableFetcherValue’s `Cardinality.MANY arm (re-key SELECT result into Map<PK, Record>, iterate the upstream input-ordered Result<RecordN<PK>> to project into List<Record> in input order). UPDATE no-match throws IllegalStateException to keep acc.size() == in.size() invariant. Audit. New load-bearing classifier-check key single-record-carrier-shape.roles-exhaustively-classified (producer on tryResolveSingleRecordCarrier, consumers on GraphitronSchemaBuilder.registerCarrierDataField and TypeFetcherGenerator.buildMutationBulkDmlRecordFetcher); mutation-dml-record-field.data-table-equals-input-table extends across both record-carrier leaves. New CarrierFieldRoleCoverageTest audits permit dispatch across consumers via grep-on-source-name; reflection-based hardening tracked at R151. Tests. Three classifier truth-table rows: MUTATION_BULK_DML_RECORD_FIELD (admit), DML_INSERT_SINGLE_LIST_DATA_REJECTED (Invariant #16), DML_INSERT_LIST_PAYLOAD_NO_CARRIER_FIELD_ROLE_REJECTED. Three execution tests in DmlBulkMutationsExecutionTest: bulkInsertWithThreeRowsInNonPkOrderPreservesInputOrderInResponse (N=3 load-bearing order assertion), bulkInsertWithSingleRowExercisesBulkLeafPath (N=1 sanity), bulkUpdateWithThreeRowsInNonPkOrderPreservesInputOrderInResponse (UPDATE order assertion). Sakila fixture gains FilmsPayload { films: [Film!] } + createFilmsPayload / updateFilmsPayload mutations. Out of scope (deferred): per-row error correlation (R12 flat-error contract preserved); affected-row-count / clientMutationId sibling permits (each is a new CarrierFieldRole permit + classifier rule); UPSERT bulk-carrier admission (R145); @service bulk-carrier symmetric path; sealed-on-kind / sub-taxonomy refactors of both record-carrier leaves. Defers consumer-side @DependsOnClassifierCheck annotations against R12’s planned error-channel.* keys; R12 lands both halves in one commit when it ships, referencing R141’s ErrorChannelRole permit Javadoc as the trust-statement anchor. Build green: full mvn -f graphitron-rewrite/pom.xml install -Plocal-db on Java 25.

  • R150 (aa7e1b0 implementation; c8bdbe5 SDL-driven invariant tightening; e96a05c SDL Scalar vs Input Object only; bfdde5e permanent Map rejection; 2c2b37a cycle detection + public-class check; bb79b63 polish ; typed LinkageError/ClassNotFoundException, nested-class .→$ retry, createBean/createBeanList plural naming, FieldKey inlined): Instantiate service-layer input beans at the @service fetcher boundary. Closes the silent ClassCastException: LinkedHashMap cannot be cast to <ConsumerBean> gap when a @service method’s Java parameter (single or List<Bean>) is a consumer-authored class mirroring an SDL input type. New CallSiteExtraction.InputBean sealed-variant arm carries the bean ClassName, the Target (RECORD/JAVA_BEAN constructor shape), and per-SDL-field FieldBinding`s. `InputBeanResolver post-processes a resolved MethodRef.Service (sibling to EnumMappingResolver.enrichArgExtractions), driving classification off the SDL side: GraphQL scalar args (including custom scalars wired via @scalarType) stay on Direct so graphql-java’s coercion delivers the consumer’s declared Java type, and GraphQL input-object args classify as InputBean or reject loudly at generation time. Rejections are exhaustive and structural ; non-public bean classes, missing record components, classes without a public no-arg constructor, recursive shapes (guarded by a path-scoped visited set, prevents StackOverflowError at gen time), Map/JDK/org.jooq./enum/array element types paired with input-object SDL slots, and list-cardinality mismatches. Map<K, V> is permanently rejected as a service-boundary anti-pattern; consumers wanting open-ended-JSON semantics declare a custom scalar via @scalarType. InputBeanInstantiationEmitter emits one createBean(Map<String, Object>) + one createBeanList(Object) helper per unique bean class on the enclosing *Fetchers class ; dedup-by-class via collectTransitively walks nested input-object leaves. Records use positional canonical-ctor; JavaBeans use no-arg + set<X> setters. ArgCallEmitter.buildArgExtraction routes the InputBean arm to the helper call. *Cycle-prevention invariant preserved: helpers reference only JDK types and the consumer’s service-package class; no helper imports a graphitron-emitted record (R94 compatibility). Tests four-tier: L1 TypeFetcherGeneratorTest pins helper signature + record/JavaBean target + createFooList plural naming + transitive dedup; L2 GraphitronSchemaBuilderTest covers singular/list InputBean classification plus SERVICE_MAP_PARAM_FOR_INPUT_OBJECT_REJECTED, SERVICE_RECURSIVE_BEAN_REJECTED, SERVICE_NON_PUBLIC_BEAN_REJECTED arms; L3 graphitron-sakila-example adds FilmReviewDetailsInput + FilmReviewTagInput SDL types and submitFilmReviewWithDetails(details: FilmReviewDetailsInput!) mutation, with consumer-authored FilmReviewDetails record + FilmReviewTag record in graphitron-sakila-service compiling against the generated helper; L4 GraphQLQueryTest.submitFilmReviewWithDetails_routesThroughInstantiatedInputBean round-trips a nested-list-bearing bean through a real GraphQL mutation, asserting service body sees typed scalar values. Out of scope (called out, not regressed): @service parameter as a jOOQ TableRecord subclass currently routes through the JavaBean setter path (not idiomatic record.from(map); tracked as a follow-up); recursion is head-only ; a @service parameter whose argMapping is a multi-segment dot-path stays on the legacy Direct arm even when the leaf SDL type is an input object; SDL oneOf polymorphic inputs and builder-pattern target classes deliberately deferred. Build green: full mvn -f graphitron-rewrite/pom.xml install -Plocal-db on Java 25.

  • R108 (a60c58a implementation): Per-variant projection on polymorphic fields. The multi-table polymorphic dispatcher’s Stage-2 per-typename SELECT now threads the parent DataFetchingFieldSelectionSet through PolymorphicSelectionSet.restrictTo(source, concreteTypeName) (new emitted helper at <outputPackage>.util.PolymorphicSelectionSet, generated by PolymorphicSelectionSetClassGenerator under generators/util/, registered in GraphQLRewriteGenerator next to ConnectionHelper), so each per-typename SELECT projects only columns whose SelectedField.getObjectTypeNames() contains that participant. The helper is a delegating wrapper that materially overrides only getFieldsGroupedByResultKey() and delegates every other DataFetchingFieldSelectionSet method to the source, keeping the nested-projection recursion in $fields (which walks sf.getSelectionSet()) intact and avoiding a widened $fields signature. Same-table interface emit site at TypeFetcherGenerator.buildInterfaceFieldsList intentionally untouched (per-spec carve-out: the LinkedHashSet dedup masks over-selection in every currently-exercised fixture); javadoc cross-reference notes restrictTo is reusable as-is when a fixture exercises the break-the-dedup shape. Tests four-tier: PolymorphicProjectionFilterPinTest (unit, UnifiedEmissionPinsTest precedent ; folder-wide $T.restrictTo(env.getSelectionSet() count == 1, single-file $$fields(env.getSelectionSet() in MultiTablePolymorphicEmitter.java count == 0); PolymorphicSelectionSetClassEmitTest (pipeline, structural pin of the emitted class ; name, modifiers, restrictTo signature, private no-arg constructor, private static final Filtered nested type implementing DataFetchingFieldSelectionSet); PolymorphicNestingFilterTest (pipeline, asserts exactly one PolymorphicSelectionSet reference per Stage-2 helper body, encoding "no further filter needed at depth"); RecordParentMultiTablePolymorphicPipelineTest extended with an asymmetric-fragment fixture (Inventory + Content sharing filmId backed by different columns on different tables) driving full SDL → classify → emit; PolymorphicProjectionQueryTest (execution, SQL-capture via jOOQ ExecuteListener ; asymmetric-Customer asserts Staff Stage-2 SELECT does not contain "staff"."first_name", asymmetric-Staff pins the inverse, symmetric keeps both; SELECTs picked by the per-typename "customerinput" / "staffinput" VALUES alias to ignore Stage-1’s narrow UNION ALL); GraphQLQueryTest.addressOccupants_asymmetricFragment_responsePayloadDropsInactiveBranch (behavioural pin on the response map). Stage-1 narrow SELECT, DataLoader-batched vs inline arms, requiredProjectionColumns, and synthetic typename / sort__ / idx projections all untouched (added outside the $fields call or inside $fields outside the selection switch). Build green: full mvn -f graphitron-rewrite/pom.xml install -Plocal-db on Java 25.

  • R147 (4fadc3d implementation; 2450971 simplify: drop the backward-compat Diagnostics.compute overload): Surface GraphitronSchemaValidator errors and warnings as LSP diagnostics. GraphQLRewriteGenerator.buildOutput() now runs the validator over the same classified bundle that yields the catalog and snapshot, packaging the result as a new ValidationReport(errors, warnings, sourceUris) record alongside a BuildArtifacts(catalog, snapshot) split of BuildOutput; the precomputed sourceUris canonical-URI set lets Diagnostics.compute short-circuit per file with one Set.contains. Workspace.setBuildOutput(BuildArtifacts, ValidationReport) replaces the old setCatalog / setCatalogAndSnapshot overloads, atomically swapping all three volatile refs; DevMojo routes both the schema-save and classpath triggers through the unified setter, so unresolved-@service-class errors surface in the editor on the next mvn compile without waiting for a schema save. Severity mapping is an exhaustive switch over the Rejection sealed hierarchy (AuthorError / InvalidSchemaError, DeferredWarning); BuildWarning maps to Warning. Freshness-aware silence policy mirrors R139: validator diagnostics fire under Built.Current only, silent under Unavailable and Built.Previous. Source attribution is "graphitron-validator", distinct from "graphitron-lsp". Two new paired classifier-check keys (source-location.absolute-path-source-name on RewriteSchemaLoader, validation-report.canonical-uri on ValidationReport.canonicalUri) pin the cross-module invariants the LSP filter relies on. Tests: ValidatorDiagnosticsTest covers severity per Rejection permit, per-file filtering, freshness gating, no-usable-location drop, and the empty-report-clears-previous-diagnostics contract at compute-call level; RejectionSeverityCoverageTest pins exhaustiveness reflectively; WorkspaceTest gets setBuildOutput swap tests; ValidationReportTest covers the from factory and the canonical-URI helper; CatalogRefreshTest migrates to the new setter. Self-review surfaced two follow-up items filed mid-implementation: R148 (source-location-skips-description.md, Backlog/bug) ; FieldDefinition.getSourceLocation() returns the start of the description block when one is present, so diagnostics on documented fields highlight the doc block rather than the field; R149 (r147-followup-end-to-end-publish-diagnostics-tests.md, Backlog/test) ; end-to-end LSP publishDiagnostics wire-test and GraphQLRewriteGeneratorTest for buildOutput() report population were deferred. Build green: full mvn -f graphitron-rewrite/pom.xml install -Plocal-db on Java 25.

  • R143 (e670fb9): Surface a date column on the rolled-up roadmap. Item gains nullable created: LocalDate / lastUpdated: LocalDate parsed via Item.from.parseDate (accepts both SnakeYAML’s auto-parsed java.util.Date shape and bare-string YAML; absent passes, malformed throws naming slug+key+value). runCreate stamps both dates today; new status subcommand (runStatus + the pure, package-visible applyStatusTransition) resolves slug or R<n> via resolveItemFile, validates target+transition against TARGET_STATES / ALLOWED_TRANSITIONS, writes new status: + fresh last-updated:, leaves created: strictly untouched (never invented for pre-R143 items), and regenerates README; Done and Discarded are rejected as targets per workflow.adoc. Markdown renderActive gains an Updated column; appendBacklogLine emits <sub>updated Y-M-D[, created Y-M-D]</sub> between description and blocked by:. AsciiDoc renderAdocStatusBoard becomes [cols="1,4,1,1,1"] with the new column; backlog adoc emits italic (updated …​); the plan-page attribute box gains Created / Updated rows (suppressed when absent). .claude/skills/roadmap/SKILL.md rewritten to invoke the status subcommand instead of hand-editing front-matter; workflow.adoc gains a bullet on the auto-stamp under "Item file conventions". Tests: RoadmapDateColumnTest (21 cases) covers create stamping, status preserving created in both present/absent shapes, rejection of invalid transitions and of Done / Discarded, slug + R<n> resolution, all four renderer cells in both markdown and AsciiDoc, the plan-page attribute box, and the parser’s "absent passes, malformed fails" semantics. Build green: full mvn -f graphitron-rewrite/pom.xml install -Plocal-db on Java 25.

  • R140 (036772f): Publish leaf-coverage report from CI. rewrite-build.yml build job grows two trunk-gated steps after mvn verify -Plocal-db ; Regenerate leaf-coverage report runs the roadmap-tool exec against the in-workspace target/leaf-coverage.jsonl traces, then Upload leaf-coverage artifact uploads the regenerated graphitron-rewrite/roadmap/inference-axis-coverage.adoc as the inference-axis-coverage artifact with if-no-files-found: error. Two new jobs in the same workflow: docs-build (needs build, trunk-gated, downloads the artifact over the committed placeholder, builds the docs module with -DskipTests, uploads the Pages artifact) and docs-deploy (needs docs-build, trunk-gated, pages: write + id-token: write, pages concurrency, github-pages environment, actions/deploy-pages@v4). .github/workflows/deploy-docs.yml deleted; the consolidation sidesteps the workflow_run-on-default-branch constraint that blocked the original R132 sketch (rewrite workflow files do not live on main). workflow_dispatch does not survive; manual re-deploy is via the Actions UI re-run on the most recent successful trunk run. inference-axis-coverage.adoc prose updated to describe the live publish chain and explain that the in-git file stays as a non-data placeholder so local doc builds and PR-preview renders find a file at the expected path. Cross-spec: when R133 flips the leaf-coverage profile to opt-in, the Regenerate leaf-coverage report step here will need -Pleaf-coverage added; R133’s own spec already owns that coordination. Pre-merge verification limited to YAML parse + additive/trunk-gated reasoning + PR-run isolation; full deploy-path verification is post-merge against https://sikt-no.github.io/graphitron/roadmap/inference-axis-coverage.html.

  • R142 (04a649b user-directive arms wired through DirectiveResolution; 4ac157e self-review fixes pinning bundled-shadows-snapshot precedence on the hover surface; a39ce93 In Progress → In Review): Phase 2 of the LSP schema-snapshot side-channel (R139 was phase 1). Three more LSP consumers now read the snapshot through the sealed DirectiveResolution.{Bundled | User | Unknown} result and light up on user-declared directives: Hovers.compute surfaces directive-name hovers (pre-coordinate branch on directive.nameNode()) and arg-name docstring fallback from InputValueShape.description(); Diagnostics.compute extends its existing outer-snapshot / inner-resolution switch on Built.Current with validateUnknownArgsAgainstSnapshot + validateRequiredArgsAgainstSnapshot package-private helpers next to the bundled equivalents; ArgNameCompletions.generate grows an LspSchemaSnapshot parameter and routes the User arm through a top-level-only userGenerate helper (nested completion stays empty until the snapshot carries input-object shapes). Hovers and completions are freshness-agnostic (stale info beats silence); diagnostics warn only under Built.Current (mirrors R139’s unknown-directive arm). Bundled-shadows-snapshot precedence (R139 settled design note 4) pinned with parallel guards on all three consumers: Hovers gates user-arm fallback on resolution instanceof DirectiveResolution.User, Diagnostics keeps the existing Bundled early-continue, and the new bundledDirectiveArgHover_ignoresSnapshotShadow / bundledDirectiveShadowedBySnapshot_routesThroughBundledPath cases anchor the guard symmetrically with DiagnosticsTest.bundledArgValidationStillFires_evenWhenSnapshotShadows. Tests. New pipeline cases: HoversTest (6 ; directive-name, arg-name, Unavailable/Previous freshness, shadow guard, bundled side-benefit), DiagnosticsTest (6 ; unknown-arg, missing-required, present-required-silent, Unavailable/Previous silence, shadow guard), ArgNameCompletionsTest (5 ; top-level snapshot args, nested-deferred-empty, Unavailable empty, Previous still emits, shadow guard). Existing bundled-path tests stay unchanged; the snapshot parameter threads LspSchemaSnapshot.unavailable() for tests not exercising the user-arm. No unit-tier additions (each consumer is a thin walker over the records CatalogBuilderSnapshotTest already pins; seal exhaustiveness is javac-checked); no schema-fixture additions to graphitron-sakila-example (R139’s @auth(role: String!) fixture remains the regression guard for the input contract); no execution-tier (LSP behaviour is observable in pipeline outputs). Audit unchanged. No new @DependsOnClassifierCheck markers ; Hovers.compute and ArgNameCompletions.generate are freshness-agnostic so no classifier guarantee is load-bearing for them, and Diagnostics.compute’s existing marker still covers the new arg-validation arms (they inherit the same `Built means clean parse dependency through the same compute body). The R139 prep-note’s "two more markers" expectation was wrong in spirit; the audit-widening decision stays tracked under R139’s "Future evolution". Deferred (called out in the spec body, unchanged): project user-declared input-object types into the snapshot (lights up nested unknown-field validation in Diagnostics and nested arg-name completion in ArgNameCompletions together as a producer-side widening); lift the directive-name hover branch into LspVocabulary once a third consumer wants it. Build green: full mvn -f graphitron-rewrite/pom.xml install -Plocal-db on Java 25.

  • R138 (a5bc310 In Progress → In Review implementation; bb415a2 plan-body self-review addendum): Extend Invariant #15 to the Payload arm. The pre-R138 classifier admitted bulk-input + single-record-payload DML mutations (e.g. createFilmsPayload(in: [FilmCreateInput!]!): FilmPayload) via R75 Phase 1’s NoBacking-promotion carrier path, bypassing the out-of-band deferred rejection in FieldBuilder.buildDmlField; the generated fetcher ended in valuesOfRows(…​).returningResult(…​).fetchOne(), which throws TooManyRowsException for every input with >1 row. Fix lifts the listInput && !returnType.wrapper().isList() predicate to MutationInputResolver.validateReturnType’s sealed-root level so it fires uniformly across all three admitted return-type arms (`ScalarReturnType(ID), TableBoundReturnType, ResultReturnType), retires the duplicated per-arm check on the ID and T arms, and rewrites the rejection message to name TooManyRowsException as the runtime failure (replacing the pre-R134 "silent drop of all-but-last-row data" framing). FieldBuilder.buildDmlField’s deferred-rejection block is deleted along with its now-unused `listInput parameter and the four kind-switch call sites. TypeFetcherGenerator.buildMutationDmlRecordFetcher collapses to a single emit shape: the if (tia.list()) empty-list short-circuit block, the dataIsList local, the payloadType ternary, and the .fetch() / .fetchOne() terminator ternary all retire; the fetcher emits a single rowType RecordN<…​> via unconditional .fetchOne(), and the Javadoc is rewritten to describe the one remaining shape. graphitron-sakila-example/schema.graphqls drops the createFilmsPayload declaration and its R134 explanatory comment block (the shape is now unclassifiable, so R134’s compilation-tier regression has no surviving anchor ; the principled outcome, not a coverage gap). Tests. New GraphitronSchemaBuilderTest.DML_INSERT_LIST_PLAIN_PAYLOAD_REJECTED row covers the plain-SDL carrier variant; the pre-existing DML_INSERT_LIST_PAYLOAD_DEFERRED row renames to DML_INSERT_LIST_PAYLOAD_REJECTED and retargets its assertion to "must return a list" + "Invariant #15" (both @record-carrier and plain-SDL variants now land at the same validator decision). MUTATION_DML_RECORD_FIELD flips from createFilms(in: [FilmCreateInput!]!) to createFilm(in: FilmCreateInput!); four SingleRecordCarrierPipelineTest fixtures (carrier_listDataField_classifiesAsMutationDmlRecordField, carrier_listDataField_dataFieldClassifiesAsSingleRecordTableField, carrier_atRecordWithNullClassName_classifiesAsMutationDmlRecordField, carrier_withDelete_rejectsAtClassifier) switch from payloadDml (bulk) to payloadDmlSingleInput; the carrier-promotion and trigger-rejection cases keep bulk input because the per-arm rejection fires first. No execution-tier test (the failure mode is TooManyRowsException thrown inside transactionResult; an "asserts throws" test carries no signal beyond classifier rejection). Defers the bulk-carrier-with-list-data-field permit (MutationBulkDmlRecordField-style sealed leaf, Result<…​>-keyed response-SELECT, list-element data-field classifier) to a future Backlog item under the slug bulk-input-single-carrier-list-data-field if a real schema surfaces a need. Build green: mvn -f graphitron-rewrite/pom.xml install -Plocal-db.

  • R130 (57d6673 Phases 1–4 implementation; 8f42848 Phase 4 compile- and execute-tier coverage): Admit the two same-table @nodeId-decoded input-field carriers (InputField.ColumnField with CallSiteExtraction.NodeIdDecodeKeys and InputField.CompositeColumnField) in @mutation inputs and @lookupKey bindings. The headline forcing function is composite-PK DELETE shaped like slettRegelverksamling(input: { id: ID! @nodeId @lookupKey }) against a composite-PK table; post-R131 the same-table arm classifies as CompositeColumnField and pre-R130 the MutationInputResolver rejected the carrier outright. Phase 1 (model + extraction-propagation fix): new InputColumnBindingGroup sealed root (MapGroup, DecodedRecordGroup) as a sibling to R50’s LookupArg, rooted at an input-field cluster rather than an outer GraphQL argument; InputField gains sealed LookupKeyField / SetField sub-interfaces permitting ColumnField and CompositeColumnField only (reference carriers stay outside the permits set); TableInputArg.fieldBindings retypes to List<InputColumnBindingGroup> and lookupKeyFields / setFields retype to the new sealed permits; EnumMappingResolver.buildLookupBindings honors the carrier’s cf.extraction() when non-Direct instead of unconditionally re-deriving from raw column metadata. The pre-R130 unconditional re-derivation discarded the resolver-supplied NodeIdDecodeKeys; the R131 follow-up SDL-boundary @nodeId guard (lookup-key-input-field-non-nodeid-decoded) papered over the bug at the cost of rejecting the shape entirely. The fix at source retires that key and replaces it with two new @LoadBearingClassifierCheck keys (mutation-input.lookup-binding-honors-carrier-extraction, mutation-input.lookup-binding-decoded-record-arity-matches-carrier-columns) paired with @DependsOnClassifierCheck consumers on the lookup-WHERE / row-IN / INSERT-arm emitters. Phase 2 (classifier admission): MutationInputResolver admits ColumnField(NodeIdDecodeKeys) and CompositeColumnField in lookup-bearing verbs; reference-carrier rejections reframe as R24-shaped Rejection.deferred(summary, "nodeidreferencefield-join-projection-form"); CompositeColumnField × INSERT carves out via Rejection.deferred(summary, "") (no roadmap item exists today; lifts when a forcing-function schema appears); CompositeColumnField outside @lookupKey position on UPDATE / UPSERT also rejects (the SET-side / INSERT-arm dispatch for composite-PK column writes is out of R130 scope). Phase 3 (emitter dispatch): buildLookupWhereSingleRow lifts a per-row Record<N> decode local to postInGuard with ThrowOnMismatch null handling (GraphqlErrorException on wrong-type id); buildBulkLookupRowIn adopts a block-lambda form for decode-bearing groups (expression-lambda preserved for the all-Direct shape so existing pipeline traces stay byte-identical); INSERT / UPSERT column lists expand CompositeColumnField into its N member columns via buildInsertColumnList; buildPerCellValueList dispatches on carrier identity for the values list; buildInsertDecodeLocals lifts decode locals into preGuard for single-row INSERT / UPSERT (per-row inside the stream lambda for bulk). Phase 4 (tests): classifier tests in MutationDmlNodeIdClassificationTest for composite-PK DELETE / UPDATE / UPSERT admission, INSERT carve-out, and single-PK extraction-propagation; the two R131 follow-up GraphitronSchemaBuilderTest.ArgumentParsingCase.LOOKUP_KEY_ON_NODEID_INPUT_FIELD_REJECTED{,COMPOSITE_PK} cases retype to LOOKUP_KEY_ON_NODEID_INPUT_FIELD_ADMITTED{,_COMPOSITE_PK} asserting MapInput.bindings[0].extraction is NodeIdDecodeKeys (single-PK) and LookupArg.DecodedRecord.bindings has the expected positional arity (composite-PK). Phase 4 (compile + execute) (8f42848): sakila-example surfaces DeleteFilmActorByNodeIdInput @table(name: "film_actor") with id: ID! @nodeId(typeName: "FilmActor") @lookupKey plus Mutation.deleteFilmActorByNodeId and Mutation.deleteFilmActorsByNodeId driving both buildLookupWhereSingleRow’s `DecodedRecordGroup arm and buildBulkLookupRowIn’s block-lambda arm end-to-end; new `keyed_node(id varchar PK, label varchar) table in init.sql plus KeyedNode @node type and Mutation.createKeyedNode driving the ColumnField(NodeIdDecodeKeys) INSERT-arm (buildInsertDecodeLocals preGuard local + buildPerCellValueList NodeIdDecodeKeys arm); five execution-tier tests in DmlBulkMutationsExecutionTest covering composite-PK DELETE single-row, composite-PK DELETE bulk row-IN, single-PK INSERT round-trip, and ThrowOnMismatch on both the lookup-key and INSERT-arm paths. Deferred (acknowledged scope reduction, not rework): composite-PK UPDATE / UPSERT execution-tier proofs (the single-row decode-local lift is shared verb-agnostically across DELETE / UPDATE / UPSERT; classifier-tier admission is pinned in MutationDmlNodeIdClassificationTest.compositePkNodeIdLookupKey{update,upsert}_admitted); reference-carrier admission stays R24-coupled (no forcing-function schema today). Retired key: lookup-key-input-field-non-nodeid-decoded retires producer-only (zero @DependsOnClassifierCheck consumers; LoadBearingGuaranteeAuditTest surfaces no orphan). Build green: full mvn -f graphitron-rewrite/pom.xml install -Plocal-db on Java 25; 23 tests in DmlBulkMutationsExecutionTest pass, 10 in MutationDmlNodeIdClassificationTest pass.

  • R75 (dbffee9 Phase 1 reshape; 862bc86 Phase 1 direct-@table two-step emit + durability pins; 2fd7598 Phase 1 follow-up SELECT coverage; 88df99a Phase 2 foundation; be26134 Phase 2 lean + R137 carve-out; 2408634 + 031c6f3 Phase 2 review fixes): Plain payload types for DML mutations. Replaces the earlier wire-format-unwrap design (PassthroughDataField permit + IdentityPassthrough capability + BuildContext.resolveReturnType short-circuit, all retired) with the structural model the SDL implies: plain SDL Object carriers promote to a new PojoResultType.NoBacking arm at type-classification time, payload-returning DML mutations classify as MutationField.MutationDmlRecordField (DELETE rejected at classify time via the compact constructor), and the data field on the carrier classifies as the new ChildField.SingleRecordTableField sibling permit with an inline SourceKey (new Reader.ResultRowWalk permit on R38’s sealed Reader interface, Wrap.Record, empty path, PK columns from the input @table; cardinality from the data field’s wrapper). DML emit becomes two-step uniformly across carrier and direct-@table shapes: PK-only RETURNING inside dsl.transactionResult(tx → DSL.using(tx)…​.), then a follow-up SELECT outside the transaction lambda ; field errors during traversal cannot undo the DML. Phase 2 (lean) widens the trigger to admit record-backed ResultType elements via a sealed DataElement sub-taxonomy (Table / Record); record-element data on @service mutations classifies as the new ChildField.SingleRecordIdentityField permit (identity-passthrough emit, no SourceKey, no SELECT), and DML mutations reject record-element carriers at classify time. Audit. Two new @LoadBearingClassifierCheck keys (mutation-dml-record-field.data-table-equals-input-table and source-key.result-row-walk-wrap-record-empty-path) pair with consumers on the mutation-fetcher RETURNING emit and the data-field response-SELECT emit. Tests. Pipeline-tier SingleRecordCarrierPipelineTest covers per-DmlKind admission, full SourceKey shape, PojoResultType split, DELETE rejection, trigger rejections, table-equality rejection, the structural two-step-emit pin on direct-@table returns, the fetcher-emitter arm-count pin, Phase 2’s record-element classification, the parameterised record-element DML rejection (INSERT/UPDATE/UPSERT), and the SingleRecordIdentityField arm pin. Execution-tier SingleRecordCarrierDmlTest covers round-trip for INSERT / UPDATE / UPSERT (new and existing rows) against sakila plus selection-set strength tests (auto-PK, DB default, @reference projection, post-UPDATE state read), plus the headline durability pins dml_persists_when_followupSelect_throws (carrier) and dml_persists_when_directReturnSelect_throws (direct-@table) via the synthetic DurabilityErrorService.synthesize mid-traversal throw. GraphitronSchemaBuilderTest.NonTableParentCase gains SINGLE_RECORD_CARRIER_DATA_FIELD, SINGLE_RECORD_IDENTITY_FIELD, and MUTATION_DML_RECORD_FIELD. Compilation-tier sakila fixture Mutation.createFilmCard(filmId: Int!): SingleFilmCardCarrier wires the @service-mutation + NoBacking-carrier + record-element-data-field shape end-to-end. Carved out to R137 (service-wrapper-composition): the 8-case execution matrix over {T, Optional, CompletableFuture, Mono, DataFetcherResult} × {Table, Record} and the data-element-aware strict service-return validator, both blocked on the @service substrate admitting wrapper layers on method return types. Build green: full mvn -f graphitron-rewrite/pom.xml install -Plocal-db on Java 25 across all 11 modules.

  • R131 (a64cd8f + 1acbaa0 + fe2de55 + beb0e92): Collapse the singular id: ID! @nodeId input-field classifier onto NodeIdLeafResolver.resolve so both arities (ID! and [ID!]) consume the same sealed Resolved outcome (SameTable | FkTarget.DirectFk | FkTarget.TranslatedFk | Rejected) through a single shared helper BuildContext.inputFieldFromNodeIdResolved. Pre-R131 the singular branch open-coded typeName inference, schema/catalog lookup, path parse, validateLift, and liftSourceColumns, then funneled every outcome into the Reference-only buildInputNodeIdReference sink ; even on the canonical same-table case where the leaf semantically filters the parent’s own rows by primary key. The reproducer schema (SlettRegelverksamlingInput @table(name: "regelverksamling") { id: ID! @nodeId } on a composite-PK table) now lands on CompositeColumnField (same-table arm), not CompositeColumnReferenceField. The duplicate BuildContext.NodeIdTypeNameInference record and inferNodeIdTypeName helper retire ; NodeIdLeafResolver.inferTypeName is the single home. buildInputNodeIdReference survives only as the id-reference synthesis shim’s sink and is documented as such. Audit in the same commit: producer @LoadBearingClassifierCheck("nodeid-fk.direct-fk-keys-match") and consumer @DependsOnClassifierCheck annotations on BuildContext.classifyInputFieldInternal and FieldBuilder.walkInputFieldConditions rewritten to describe the post-R131 shape; the stale "`CompositeColumnReferenceField` may represent a same-table PK filter" reading is gone (MutationInputResolver rejects all four @nodeId-decoded input-field carriers as deferred; R130’s post-R131 pivot retains scope for the same-table column-direct half ; ColumnField with NodeIdDecodeKeys and CompositeColumnField, the carriers the post-R131 classifier produces from SlettRegelverksamlingInput-shaped schemas ; and defers the genuinely-joined ReferenceField half per R24’s "wait for forcing-function schema" discipline). *Tests: NodeIdPipelineTest four-corner pipeline pins for singular + (same-table | FkTarget.DirectFk) × (single-PK | composite-PK) ; InputCase.EXPLICIT_NODE_ID_DIRECTIVE updated to CompositeColumnField, new EXPLICIT_NODE_ID_DIRECTIVE_SINGLE_PKColumnField, new InputReferenceCase.REFERENCE_TO_COMPOSITE_PK_NODE_TYPECompositeColumnReferenceField with positionally-aligned liftedSourceColumns, NODE_TARGET_NO_METADATA_PK_FALLBACK updated to ColumnField, new ArgumentSameTableNodeIdCase.SAME_TABLE_SCALAR_COMPOSITE_PK pinning FieldBuilder.classifyArgument emits BodyParam.RowEq over the parent’s PK columns; MutationDmlNodeIdClassificationTest.nodeIdFieldInInput_deferred rejection text shifts to CompositeColumnField. Compilation tier (sakila example): new FilmActorSingularNodeIdFilter @table(name: "film_actor") { id: ID! @nodeId(typeName: "FilmActor") } plus Query.filmActorBySingularCompositeNodeId(filter:) emits DSL.row(table.ACTOR_ID, table.FILM_ID).eq(id). CompositeDecodeHelperRegistry.buildHelper drive-by switches the singular non-list branch from var r = …​ to a typed Record<N> declaration via a new typedRecord helper (caught by GeneratedSourcesLintTest.varGuard once the singular composite-PK path reached this branch for the first time). Reachability claims (multi-hop, condition-step, and TranslatedFk rejections) hold no-op-by-construction via the shared route; pipeline tests anchor on the resolver’s shared marker constants (LIFT_FAILURE_MARKER, CONDITION_STEP_MARKER), not on copied substrings. Follow-up 1acbaa0 (FK-target / NodeType-keyColumns permutation): NodeIdLeafResolver.permutationToKeyColumns replaces the strict sameColumnsBySqlName predicate ; when the terminal hop’s target-side columns equal the NodeType’s @node(keyColumns:) as a multiset (any order), the DirectFk arm permutes joinPath.liftedSourceColumns() into keyColumns order before constructing the carrier; the @LoadBearingClassifierCheck description rewrites "positionally match" to "equal as a multiset, in any order" and tightens the carrier guarantee. Pinned by InputFieldFkTargetNodeIdCase.FK_TARGET_REORDERED_KEY_PERMUTATION_DIRECT_FK{,_SINGULAR} over the new reordered_pk_parent/reordered_fk_child fixture (declared FK target order (pk_b, pk_c, pk_a), NodeType keyColumns [pk_a, pk_b, pk_c]). Resolves a latent zero-rows regression in the downstream opptak-subgraph regelverksamlingId schema. Follow-up fe2de55 + beb0e92 (@lookupKey composition guard): post-R131 the singular same-table @nodeId carrier is InputField.ColumnField (or CompositeColumnField), so EnumMappingResolver.buildLookupBindings’s pre-R131 structural rejection no longer fires; `beb0e92 moves the guard upstream to sdlField.hasAppliedDirective(DIR_NODE_ID) so both arities surface the same diagnostic ("expose the decoded key column(s) explicitly via @field instead, or move @lookupKey to the outer argument"). New LoadBearingClassifierCheck key lookup-key-input-field-non-nodeid-decoded, new test cases GraphitronSchemaBuilderTest.ArgumentParsingCase.LOOKUP_KEY_ON_NODEID_INPUT_FIELD_REJECTED{,_COMPOSITE_PK}. NodeIdLeafResolver.validateLift / liftSourceColumns tightened from package-private to private (no external callers after the routing collapse). Two follow-on Backlog stubs filed for material gaps surfaced in self-review: R135 (multi-hop @nodeId permutation pipeline test, to prove the multi-hop case the commit asserts works by construction) and R136 (execution-tier round-trip for the FK-permutation case via graphitron-sakila-example + GraphQLQueryTest). Build green: full mvn -f graphitron-rewrite/pom.xml install -Plocal-db on Java 25, 1900+ tests passing.

  • R42 (506c704 + 5d90719): Lift ChildField.ColumnReferenceField out of TypeFetcherGenerator.STUBBED_VARIANTS for the CallSiteCompaction.Direct + FK-only path. New InlineColumnReferenceFieldEmitter builds the $fields switch-arm body as a single-column correlated subquery (DSL.field(DSL.select(<terminalAlias>.<COL>).from(<terminalAlias>).join(…​).where(<correlation>).limit(1)).as("<fieldName>")), mirroring InlineTableFieldEmitter’s shape collapsed to a scalar (`DSL.field, not DSL.multiset). TypeClassGenerator.$fields gains a case ChildField.ColumnReferenceField crf arm; TypeFetcherGenerator moves the leaf from STUBBED_VARIANTS into PROJECTED_LEAVES and the fetcher switch arm becomes a no-op. FetcherEmitter wires new ColumnFetcher<>(DSL.field("<name>")) for the Direct shape. The two non-lifted shapes surface at build time, not runtime: GraphitronSchemaValidator.validateColumnReferenceField rejects NodeIdEncodeKeys with Rejection.Deferred keyed to nodeidreferencefield-join-projection-form (R24) and any JoinStep.ConditionJoin-in-path with Rejection.Deferred keyed to the newly-allocated R129 (column-reference-on-scalar-field-condition-join). The validator/emitter contract carries the @LoadBearingClassifierCheck / @DependsOnClassifierCheck annotation pair (keys column-reference-field-no-nodeid-encode-keys and column-reference-field-no-condition-join-step), so LoadBearingGuaranteeAuditTest walks the dependency and the FetcherEmitter NodeIdEncodeKeys runtime stub for ColumnReferenceField retires as defence-in-depth without an annotated guarantee (CompositeColumnReferenceField’s parallel arm is unaffected; R24 still owns it). Tests: `ColumnReferenceFieldValidationTest restructured for the four-shape matrix (Direct + FK-only passes; Direct + ConditionJoin and NodeIdEncodeKeys + FK-only get the deferred messages; empty path keeps the structural "path is required" error); NestingFieldValidationTest stubbed-nested cases switched to CompositeColumnReferenceField (which remains stubbed); new pipeline-tier ColumnReferenceFieldPipelineTest covers single-hop and multi-hop projection plus ColumnFetcher wiring; sakila-example schema adds Film.languageName: String @field(name: "NAME") @reference(path: [{key: "film_language_id_fkey"}]); GraphQLQueryTest.films_languageName_resolvesViaScalarReference covers the execution tier against PostgreSQL. Build green: full mvn -f graphitron-rewrite/pom.xml install -Plocal-db on Java 25.

  • R43 (scoping change, this commit): The scalar/enum-return form of @tableMethod (originally tracked as the carve-out under tablemethod-scalar-return.md) is closed by rejecting the shape at classification rather than implementing it. TableMethodDirectiveResolver now rejects any non-TableBoundReturnType return as a structural schema error ("@tableMethod requires a @table-annotated return type") at both root and child sites; the previous gating on isRoot is gone, and the resolver’s sealed Resolved collapses to {TableBound, Rejected} (the NonTableBound arm was the only producer of TableMethodField with a non-table return and is dead code now). ChildField.TableMethodField.returnType() is tightened from ReturnTypeRef to ReturnTypeRef.TableBoundReturnType to express the classifier guarantee in the model. TypeFetcherGenerator.STUBBED_VARIANTS keeps the TableMethodField entry with reworded summary ("child @tableMethod (table-bound return) not yet implemented") and a renamed planSlug tablemethod-child-table-bound; the roadmap file is renamed to match and the item body now scopes R43 narrowly to the table-bound child case (QueryField.QueryTableMethodTableField at the root already ships in IMPLEMENTED_LEAVES). Pipeline tests in GraphitronSchemaBuilderTest.UnclassifiedFieldCase gain two cases (TABLEMETHOD_AT_ROOT_WITH_SCALAR_RETURN_REJECTED, TABLEMETHOD_ON_CHILD_WITH_SCALAR_RETURN_REJECTED) asserting the exact rejection message on both sites; TableMethodFieldValidationTest fixtures stop constructing the variant with ScalarReturnType (no longer reachable from real classification) and use TestFixtures.tableBoundFilm instead. Rationale: @tableMethod’s purpose is to bind a developer-authored jOOQ table method, which by construction returns a generated jOOQ table class. A scalar/enum return cannot be made to work; calling the shape "deferred" misled authors with a roadmap link to functionality that would never arrive. Build green: full `mvn -f graphitron-rewrite/pom.xml install -Plocal-db on Java 25.

  • R101 (ef66e8b Phase 1; fbe354e Phase 2; 46f08f8 Phase 3; this commit Phase 4): Custom-scalar Java type configuration. The hardcoded five-site spec-built-in switch (ServiceCatalog.mapToJavaTypeName, FieldBuilder.mapGraphQLTypeToReflectType, RowsMethodShape.standardScalarJavaType, AppliedDirectiveEmitter.emitInputType, GraphitronSchemaClassGenerator’s literal `.additionalType(…​) block) is retired in favour of a single ScalarTypeResolver source-of-truth carrying a sealed ScalarResolution.{Resolved | Rejected} outcome. Consumers bind a custom scalar by pointing at a public static final GraphQLScalarType constant on the classpath, either by directive (scalar Money @scalarType(scalar: "com.example.Scalars.MONEY")) or by the graphql-java-extended-scalars convention table (scalar BigDecimal resolves to ExtendedScalars.GraphQLBigDecimal whenever the artifact is on the consumer’s compile classpath, no directive needed). Graphitron reflects on the constant’s Coercing<I, O> parameters to recover the Java type, and emits .additionalType(…​) automatically. Migration: consumers running on the rewrite must remove their manual .additionalType(ExtendedScalars.GraphQLBigDecimal) / .additionalType(consumerScalar) calls from buildSchema(…​) hooks for any scalar graphitron now resolves; graphql-java’s GraphQLSchema.Builder.additionalType rejects duplicate type names at build time, so leaving the call in turns into a SchemaProblem rather than silent tolerance. Resolution order: spec built-ins (Int, Float, String, Boolean, ID) win; @scalarType beats the convention layer; convention lands when the SDL name matches an entry on ScalarTypeResolver’s 30-entry table and `graphql.scalars.ExtendedScalars is on the classpath. Unresolved → hard validation error pointing at @scalarType(scalar:) or extended-scalars as the fix; no silent fallback to Object. Phase 4 housekeeping: LSP completion on @scalarType(scalar: |) suggests convention-table FQNs (preferring the entry that matches the enclosing scalar’s SDL name); LSP diagnostics surface malformed-FQN and unknown-class cases inline against the catalog’s external-reference scan; new Behavior.ScalarTypeBinding arm on the @scalarType(scalar:) coordinate; Documentation/code-generation-triggers.adoc gains a @scalarType row; the scalar resolution story is documented in the manual reference page. Build green: full mvn -f graphitron-rewrite/pom.xml install -Plocal-db on Java 25.

  • R127 (8310179 + 6308198): Accept List<XRecord> as well as Result<XRecord> at root @service on a @table-bound list return. The classifier’s strict catalog-side TypeName.equals check now returns null from ServiceDirectiveResolver.computeExpectedServiceReturnType for the TableBoundReturnType + List arm, and a new resolver-side validateRootListTableBoundReturnPair rejects any reflected method-return that isn’t exactly org.jooq.Result<XRecord> or java.util.List<XRecord> (graphql-java treats both identically; Result extends List). TypeFetcherGenerator.buildQueryServiceTableFetcher and .buildMutationServiceTableFetcher read MethodRef.returnType() for the List arm so the generated local declaration tracks whichever shape the developer chose. Single cardinality stays strict via the catalog. Annotations. A new LoadBearingClassifierCheck key service-resolver-root-list-record-return-pair owns the resolver-side pair check; the existing service-catalog-strict-service-return description narrows to the Single arm + ResultReturnType paths; both root emitters declare both keys via @DependsOnClassifierCheck. Tests. TestServiceStub.getFilmsAsList returns List<FilmRecord>; ServiceRootFetcherPipelineTest gains a positive case (serviceWithListOfRecordReturn_isAccepted) and a negative case (serviceWithWrongInnerGenericOnList_surfacesAsValidationErrorWithPairedShapes) asserting the rejection names both accepted shapes, the actual mismatched shape, and carries the "service method could not be resolved ; " prefix the Single-arm rejection wears. LoadBearingGuaranteeAuditTest covers the new key automatically. Workflow note. The item was filed directly In Progress: the inbound was framed as an operational bug report and the agent began implementation before the Backlog → Spec → Ready gate; CLAUDE.md was tightened in the same branch so the next operational-looking inbound (stack trace plus "make it accept X") doesn’t slip past. Build green: full mvn -f graphitron-rewrite/pom.xml install -Plocal-db on Java 25, 1564 tests passing.

  • R68 (f5c75ef; Phases 1a–6 SHAs compressed by upstream rebase): Diataxis user manual absorbs the legacy graphitron-codegen-parent/graphitron-java-codegen/README.md into the deployed site. Five top-level chapters under /docs/manual/ (index, tutorial, how-to, reference, explanation) mirror the Diataxis quadrants. Tutorial (six pages anchored to graphitron-sakila-example) verified by TutorialSmokeTest ; a @QuarkusTest inside the example module replaying each page’s HTTP query against the JAX-RS endpoint. Reference: 26 directive pages 1:1 against directives.graphqls (drift-pinned by DirectiveDocCoverageTest); mojo-configuration.adoc reflected from the Mojo’s @Parameter-annotated fields (MojoDocCoverageTest); diagnostics-glossary.adoc covering the 16-code RejectionKind/AttemptKind/EmitBlockReason closed set (DiagnosticsDocCoverageTest); deprecations.adoc extracted from SDL @deprecated() markers with @index allow-listed for the GraphQL-spec-disallowed whole-directive case (DeprecationsDocCoverageTest); plus runtime-api.adoc and special-interfaces.adoc as hand-curated prose. How-to: 14 recipe-shaped pages with "verified by" pointers into the graphitron-sakila-example/src/test/java/…​/querydb/ consumer test surface (including test-your-schema.adoc, net-new prose with no legacy precedent). Explanation: six pages (why-database-first, why-jooq-and-graphql-java, how-it-works, classifier-mental-model, batching-model, design-decisions). Cutover (Phase 6): docs/quick-start.adoc:15 flipped from the legacy GitHub README pointer to the in-tree xref:manual/reference/directives/index.adoc. Rework pass (f5c75ef): cleared four R<n> leaks from user-facing prose flagged by the In Review reviewer ; R47 reference in external-code.adoc:116 dropped, R114 in multi-hop-nodeid-filter.adoc:15 rephrased to feature-by-name, the until R61 historical-Invariant bullet in result-types.adoc:142 dropped entirely, and the R75: Javadoc prefix on PassthroughDataField in ChildField.java:361 stripped at source (the migration fragment and roadmap/inference-axis-coverage.adoc regenerated). The Phase 1a–6 implementation SHAs the spec body cited (3afc278, fa36dbc, d0c63c4, 8f7d412, 1ea0855, 868593a, d796c4c, 3f6ec55, 23c2056, 863d8be) no longer resolve in local history ; compressed by an upstream rebase before the rework cycle. Build green: full mvn -f graphitron-rewrite/pom.xml install -Plocal-db on Java 25 with 312 + 25 tests passing, all five drift-protection verifiers green.

  • R44 (b978e69): Deprecate @multitableReference, mirroring the @notGenerated removal end-to-end. The directive stays SDL-declared in directives.graphqls so consumer schemas still parse, but FieldBuilder.classifyField now rejects every application with an UnclassifiedField carrying a Rejection.directiveConflict(List.of(DIR_MULTITABLE_REFERENCE), "@multitableReference is no longer supported. Remove the directive; the rewrite generates multi-table interface dispatch from @discriminate / @discriminator without an explicit multitable-reference path."). The rejection is ordered above detectChildFieldConflict so the deprecation message wins over a mutual-exclusivity reason when the field also carries a conflicting directive (the load-bearing ordering invariant the spec called out, mirroring @notGenerated’s precedent). Model. `ChildField.MultitableReferenceField record and its permits entry deleted; sealed-switch exhaustiveness propagates the removal to GraphitronSchemaValidator (dispatch arm + validateMultitableReferenceField method gone) and TypeFetcherGenerator (STUBBED_VARIANTS entry + dispatch arm gone, with the [deferred] multitable-reference-on-scalar slug retiring as a dead anchor). detectChildFieldConflict’s mutual-exclusivity slot list drops `DIR_MULTITABLE_REFERENCE as dead vocabulary; the BuildContext.DIR_MULTITABLE_REFERENCE constant, its PASSTHROUGH_FORBIDDEN_DATA_FIELD_DIRECTIVES membership, and the SchemaDirectiveRegistry.GENERATOR_ONLY_DIRECTIVES entry all retained per the deprecated-but-membership-retained precedent (the SDL declaration is still present). Tests. Pipeline-tier MultitableReferenceFieldCase rewritten from "produces a MultitableReferenceField`" to two cases: `REJECTED asserts UnclassifiedField with the deprecation reason, and REJECTED_WINS_OVER_CONFLICT pairs @multitableReference with @service and asserts the deprecation reason wins over the mutual-exclusivity reason (locking the ordering invariant). The redundant MULTITABLE_REFERENCE_AND_SERVICE_CONFLICT case retires from the child-field conflict suite; MultitableReferenceFieldValidationTest deleted outright. Docs. directives.graphqls description rewritten in the @notGenerated removal shape; docs/manual/reference/directives/multitableReference.adoc rewritten as a deprecation page (opener, SDL signature, Migration, Diagnostic, Constraints, See also); new === @multitableReference subsection under "Hard removals" in migrating-from-legacy.adoc; cross-references to @multitableReference retargeted to @discriminate / @discriminator in reference.adoc, join-with-references.adoc, polymorphic-types.adoc, notGenerated.adoc, and the directive indices (entry moved to the "Rejected by the rewrite" category, gains the (rejected, remove from the schema) annotation); dispatch table row in code-generation-triggers.adoc rewritten in the @notGenerated row’s shape. Build green: full mvn -f graphitron-rewrite/pom.xml install -Plocal-db on Java 25.

  • R124 (7882caf + f8d5300): Source the codegen reflection classpath from the project’s compile classpath + reactor sibling target/classes, not the plugin realm. AbstractRewriteMojo.withCodegenScope builds a URLClassLoader over project.getCompileClasspathElements() plus the existing resolveClasspathRoots() set, parented on the plugin loader; the loader is threaded through a new RewriteContext.codegenLoader field to the 22 in-process Class.forName(name, false, loader) sites and also installed as TCCL for the duration of the scope (defense-in-depth for third-party transitive callees), restored in finally, and closed to release JAR file descriptors (matters for DevMojo’s per-cycle regeneration). The lone `DataFetchingEnvironment reflection in ClassAccessorResolver stays plugin-internal. API surface: RewriteContext gains a non-null codegenLoader field (eight-arg compact + seven-arg + six-arg back-compat overloads default it to TCCL for unit-tier callers); BuildContext.codegenLoader() is a thin passthrough mirroring nodeIdLeafResolver() and BuildContext.ctx is now @NonNull-enforced via Objects.requireNonNull (the three unit-tier tests that previously passed (null, _, null) now construct a deterministic stub via the 6-arg overload); JooqCatalog takes a (String, ClassLoader) constructor with a one-arg TCCL-defaulting back-compat overload; CheckedExceptionMatcher.unmatched/covers and ServiceCatalog.argExtraction gain a ClassLoader parameter; TypeBuilder.validateExceptionClass and FieldBuilder.checkDeclaredCheckedExceptions flip from static to instance (single same-class callers; the explicit-parameter sibling lives where it crosses a class boundary). Migration: <plugin><dependencies> blocks deleted from graphitron-sakila-example/pom.xml and the basic-generate IT pom; the IT now declares graphitron-sakila-db as a normal top-level <dependency>, locking the contract in the IT itself. Tests: new pipeline-tier CodegenLoaderTest stages a hand-rolled .class file (Java 17 encoding inlined as byte[], so the test does not need a compiler on its own classpath) under a fake target/classes directory, wires its path through project.getCompileClasspathElements(), and asserts (a) the staged class is not on the test JVM’s classpath, (b) inside withCodegenScope the ctx.codegenLoader() resolves it, (c) the TCCL inside the scope is the codegen loader, (d) the previous TCCL is restored after. The compile- and execution-tier load-bearing migration test is the graphitron-sakila-example reactor build, which now compiles and runs against the live schema with no <plugin><dependencies> block. Docs: new "Codegen classpath" section in docs/manual/reference/mojo-configuration.adoc names the new contract and the rare legitimate <plugin><dependencies> case (pinning a different version through the parent chain). Architect-review tightening (f8d5300) added a load-bearing comment to DevMojo.execute explaining why the captured initialCtx must only be read for path-shaped fields (its loader is closed by the time setup proceeds), and one-line policy notes on the two instance-method helpers explaining why they’re not static and why the cross-class siblings take an explicit ClassLoader parameter instead. Build green: full mvn -f graphitron-rewrite/pom.xml install -Plocal-db on Java 25.

  • R83 (b3c5b6c + 1187fa5): Pipeline + compilation + execution tier coverage for the multi-schema jOOQ fixture R78 introduced. Three independent tiers ride the multischemafixture catalog (multischema_a + multischema_b, the cross-schema FK gadget → widget, the colliding event table). Pipeline tier: MultiSchemaPipelineTest (annotated @PipelineTier, in graphitron/src/test) drives a slim SDL through GraphitronSchemaBuilder against jooqPackage=multischemafixture and asserts at two typed surfaces. Model-level: TableRef.tableClass() segmentation for the unique-per-schema (Widget → multischema_a.tables.Widget), qualified (@table(name: "multischema_a.event")multischema_a.tables.Event), and cross-schema-FK-target (Gadget → multischema_b.tables.Gadget) resolution paths; cross-schema JoinStep.FkJoin.fk().keysClass() routes to multischema_b.Keys (the FK-holder schema, not the target’s schema ; the R78 bug case); firstHop.targetTable().tableClass() lands on multischema_a.tables.Widget. Structural emit-side: a typed walk over TypeSpec.methodSpecs[].parameters[].type / returnType / fieldSpecs[].type plus parsed imports from the rendered JavaFile, asserting every ClassName whose canonical name starts with the multischema-fixture root lives under a multischema_a / multischema_b sub-package ; the bare-root R78 bug shape (<jooqPackage>.tables.X, <jooqPackage>.Keys, <jooqPackage>.Tables) cannot appear anywhere typed-reachable from a TypeSpec. Targeted positive assertions read parameter types directly off QueryConditionsGenerator and TypeClassGenerator output. Compilation tier: third rewrite-generate-multischema graphitron-maven-plugin execution in graphitron-sakila-example/pom.xml consuming a new src/main/resources/graphql/multischema.graphqls (the same three shape cases as the pipeline test) and writing to a disjoint no.sikt.graphitron.generated.multischema output package. The full mvn install -Plocal-db reactor compiles the multischema slice against the live multi-schema jOOQ catalog; a regression that re-derives a ClassName from the bare jooqPackage emits source that does not exist (root.Keys / root.tables.Widget never resolve under multi-schema codegen) and the maven-compiler-plugin fails the build. Execution tier: MultiSchemaQueryTest (annotated @ExecutionTier, in graphitron-sakila-example/src/test) loads the multischema slice’s own Graphitron.buildSchema(…​) and issues three queries against the rewrite_test PostgreSQL ; the cross-schema FK round-trip (gadgets { gadgetId note widget { widgetId name } }), the schema-A unique resolution (widgets), and the qualified-form resolution (events). Seed rows added to init.sql for one widget, two gadgets pointing at it, plus one row in each event collision table. Self-review pass (1187fa5): the principles-architect agent flagged that the original b3c5b6c rendered-text substring scans (assertThat(rendered).contains("…​multischema_a.tables.Widget") plus a leading-space negative form to disambiguate root-truncated FQNs from correctly-segmented ones) were structurally indistinguishable from the body-content assertions banned at every tier. Replaced with the typed walk above; positive assertions now read parameter types directly (no string scan, no JavaPoet import-vs-inline coin-flip). The R78 invariants (TableRef.tableClass(), ForeignKeyRef.keysClass()) fit the @LoadBearingClassifierCheck / @DependsOnClassifierCheck pattern but the global annotation-pair sweep was deferred to R125 to keep R83’s scope on "make the multi-schema fixture earn its keep" rather than expanding into a broader principle-enforcement sweep. Build green: full mvn -f graphitron-rewrite/pom.xml install -Plocal-db on Java 25, all 11 reactor modules SUCCESS, both new test classes pass (7 pipeline tests + 3 execution tests, plus the R78 unit-tier JooqCatalogMultiSchemaTest).

  • R86 (a53502f + ede87f1): Architecture chapter for the user-manual site. New typed-rejection.adoc consolidates the sealed Resolved narrative across the thirteen *DirectiveResolver siblings, the Rejection taxonomy (AuthorError.{UnknownName | Structural | AccessorMismatch}, InvalidSchema.{DirectiveConflict | Structural}, Deferred), and the BuildContext.candidateHint Levenshtein contract; D10 draws the Rejection sealed hierarchy with LookupKeyDirectiveResolver.Resolved.{Ok, Rejected} overlaid as a worked example. README.adoc rewritten as an intent-routed chapter index with D1 (build pipeline). getting-started.adoc § Federation and § Dev loop each gain a marked === How this is wired (for contributors) subsection (D9 federation entity flow, D7 dev-loop runtime framing). runtime-extension-points.adoc § Where the interface comes from prepended with the per-app-emission rationale plus D4 (request lifecycle); code-generation-triggers.adoc § Scope gains D3 (scope state machine). Four manual xrefs restored (explanation/index, classifier-mental-model, how-it-works, how-to/test-your-schema). New SealedHierarchyDocCoverageTest walks Rejection.permits() transitively with bidirectional drift protection ; alternation built from the live permit set so future top-level branches extend coverage automatically. ManualXrefIntegrityTest extended to remap rendered architecture/ paths back to graphitron-rewrite/docs/ (mirrors the stage-architecture block’s README.adoc → index.adoc rename) so source-tree resolution stays honest without staging. rewrite-design-principles.adoc framing line and the Builder-step-results-are-sealed section collapsed to forward pointers into typed-rejection.adoc. Two acknowledged deviations: wire-format-encoding principle stayed in rewrite-design-principles.adoc (page name and consolidation list are about typed rejection, not wire-format decode at the DataFetcher boundary), and existing tables in code-generation-triggers.adoc weren’t converted to enriched form (read fine as-is). Build green; all five *DocCoverageTest siblings pass.

  • R119 (531495a + 6a644f1 + 698a21a + 22dfb8a + 54fc7c9 + 4ae827d + 04daf62 + cc5417c): LSP completion / diagnostics keyed by GraphQL schema coordinates. Sealed SchemaCoordinate (Directive / DirectiveArg / InputType / InputField) plus sealed Behavior overlay (ClassNameBinding / MethodNameBinding(classNameCoord) / CatalogTableBinding / CatalogColumnBinding / CatalogFkBinding / ArgMappingBinding) live in a new LspVocabulary record that wraps a parsed TypeDefinitionRegistry of the bundled directives.graphqls. LspVocabulary constructor enforces the structural invariant ; every overlay coordinate must resolve against the registry ; and throws LspStartupException otherwise; DriftDetectionTest builds the production overlay against the real SDL so R110-style drift is a startup failure, not a silent unknown-directive at request time. Single RewriteSchemaLoader.directivesSdl() accessor consolidates what was two private constants. Seven consumers (Diagnostics, Hovers, ClassNameCompletions, MethodCompletions, FieldCompletions, TableCompletions, ReferenceCompletions) migrate to behavior-arm dispatch via LspVocabulary.coordinateAt(directive, pos, source) + behaviorAt(coord); the @sourceRow gap R110 left in place closes here as a side-effect of the unification. Five DX wins fall out of the parse: unknown-directive / unknown-arg / required-arg diagnostics (Warning severity) in Diagnostics, arg-name completion in a new ArgNameCompletions provider chained last in coordinateBasedCompletions, and SDL-docstring fallback hover via LspVocabulary.descriptionOf. Deletions: DirectiveDefinitions.java (124 lines), DeprecationMarkers.java (164 lines, two regex patterns), SdlAction.DeprecationTarget collapses into SchemaCoordinate. SdlActionDriftTest rewritten to read deprecations off LspVocabulary.deprecatedCoordinates() (native @deprecated(reason:) for member-level; docstring @deprecated token-scan for whole-directive). New tests: LspVocabularyTest (10 cases pinning structural invariant + deprecation surface), CoordinateAtTest (cursor-to-coordinate across flat / single-level / multi-level nesting), ArgNameCompletionsTest (5 cases), DriftDetectionTest; consumer tests gain @sourceRow regression-guard cases. Self-review (04daf62) lifted siblingStringAt to LspVocabulary (~120 duplicated lines retire from MethodCompletions / Hovers / Diagnostics) and filed R123 for the MethodNameBinding + METHOD_VALIDATING_DIRECTIVES enclosing-directive-context smell that surfaced when the consumers stabilised. Findings noted at approval (non-blocking): (1) Diagnostics.METHOD_VALIDATING_DIRECTIVES is the renamed shadow of the previous VALIDATE_METHOD set; the spec said this would be replaced by MethodNameBinding arms only being attached where method validation applies, but the structural redesign got deferred to R123 instead of landing here. (2) SdlActions.detectLegacyNameSites calls LspVocabulary.load() on every detection (every code-action request re-parses the SDL + re-runs the structural-invariant loop); the workspace’s vocabulary instance is the once-only one, but the SdlAction.Detector signature doesn’t accept a vocabulary so per-request re-parsing is the only option without a contract change. Code-action requests aren’t on the keystroke hot path and the parse is small (~25 directives), so this is minor, but it contradicts the spec’s "shape, not state, read once" framing. (3) The L1 unit BehaviorTest per-binding-arm suite the spec called out didn’t land as a separate file; arm coverage is folded into the consumer tests, which keeps the assertion shapes but loses the tier-by-tier mapping. Build green: full mvn -f graphitron-rewrite/pom.xml install -Plocal-db on Java 25, all 11 reactor modules SUCCESS, 187 LSP tests pass.

  • Discarded: collapse BatchKeyField validator/emitter redundancy (collapse-tabletargetfield-redundancy, R4): superseded wholesale by R58 Phase G (commit 3dcd3c6), which introduced the orthogonal ConditionJoinReportable capability and collapsed the four unsupportedReason overloads in SplitRowsMethodEmitter to a single capability dispatch + the validator’s matching 4-arm instanceof chain to a single instanceof ConditionJoinReportable check. R4’s success criteria (validator 4 → 1 arms, emitter overloads gone, lock-step compiler-enforced) are all met in current trunk; the implementation diverged from R4’s literal proposal ; the predicate lives on a purpose-built ConditionJoinReportable rather than as a default on BatchKeyField, because ServiceTableField is a BatchKeyField without the condition-join concern, so the narrower capability is the cleaner split. Item file deleted in this transition; the work itself shipped under R58.

  • Surface silent @splitQuery on @record-parent fields as a build warning (classification-vocabulary-followups, R3, 17cc1a9 + 715a439): FieldBuilder.classifyChildFieldOnResultType now emits a BuildWarning via ctx.addWarning(…​) at both seams that head into RecordTableField / RecordLookupTableField ; the @sourceRow branch (top of the if (DIR_SOURCE_ROW) block, before SourceRowDirectiveResolver runs) and the regular @record-parent branch (immediately after resolveReturnType confirms ReturnTypeRef.TableBoundReturnType, before path / table-field-components / batch-key rejection guards). Holistic surfacing: an unrelated rejection on the same field (bad lifter signature, unresolvable @reference, FK ambiguity) doesn’t suppress the redundancy advisory. Message names the field coordinate (<ParentType>.<fieldName>) and contains the substring "@splitQuery is redundant on a @record-parent field". Channel and prose form mirror the @table-shadowed-by-@record precedent at TypeBuilder.java:663; no new public API, no marker constant (deferred until R121’s LSP arm earns the second consumer). Closes the long-standing promise at code-generation-triggers.adoc:105. Tests. Five pipeline-tier fixtures in GraphitronSchemaBuilderTest: SPLIT_QUERY_ON_RECORD_PARENT_WARNS_TABLE_FIELD, SPLIT_QUERY_ON_RECORD_PARENT_WARNS_LOOKUP_FIELD, SPLIT_QUERY_WARNS_ALONGSIDE_RECORD_PARENT_REJECTION (regular path; the last asserts UnclassifiedField + warning); SPLIT_QUERY_WARNS_ON_SOURCE_ROW, SPLIT_QUERY_WARNS_ALONGSIDE_SOURCE_ROW_REJECTION (@sourceRow path; the last asserts UnclassifiedField with RejectionKind.AUTHOR_ERROR + warning). Each asserts the classification arm via isInstanceOf(…​) plus the warning’s message-substring on schema.warnings(). Out of scope, follow-ups filed: LSP-tier diagnostic for the same warning (R121, where the marker constant earns its keep with a real second consumer); FkJoin.alias dead-storage cleanup (R120); generalising the BuildWarning channel into a WarningKind enum (no fourth producer to justify the lift); rejecting @splitQuery on @record-parent fields (the directive remains classified-but-no-op so existing schemas keep building). Build green: full mvn -f graphitron-rewrite/pom.xml install -Plocal-db on Java 25.

  • Add Record1<T> source-shape support alongside Row1<T> on the @service classifier path (emit-record1-keys-instead-of-row1, R61, 742f11bd + 3d01c218 + 4ffdfc02 + 8fc61b95 + bfeae318): developers choose either source shape at the @service source declaration ; Set<Row1<Integer>> (Row surface, no value1()) or Set<Record1<Integer>> (Record surface, with value1()) ; and variant identity tracks the chosen shape so emit sites pattern-match instead of re-deriving. The classifier already routed List<Row<N>> / Set<Row<N>> to RowKeyed / MappedRowKeyed and List<Record<N>> / Set<Record<N>> to RecordKeyed / MappedRecordKeyed; this iteration made each variant’s keyElementType() and javaTypeName() reflect the developer’s choice exactly and threaded that through the emit chain. Model. BatchKey.keyElementType() becomes the single source of truth via a default sealed switch on the root: RowKeyed / MappedRowKeyed / LifterRowKeyed produce RowN<…​>; RecordKeyed / MappedRecordKeyed / AccessorKeyedSingle / AccessorKeyedMany produce RecordN<…​>. javaTypeName() per-variant routes to a shared containerType(container, shape, cols) helper. Variant rename. AccessorRowKeyed{Single,Many}AccessorKeyed{Single,Many} (8fc61b95): the Row discriminator was leaking an emit-site detail (which jOOQ-typed local the framework picks for the projected key) into variant identity. There is no developer-supplied source on these arms ; the role is the lift-back into Graphitron scope after a @service / @externalField returning a TableRecord ; so the projection axis no longer encodes in the name. Used by ServiceTableField and RecordTableField; the source-shape constraint lives in javadoc and is enforced by FieldBuilder.deriveBatchKeyFromTypedAccessor. Generators. GeneratorUtils.buildKeyExtraction (ParentKeyed) forks by variant identity: RowKeyed / MappedRowKeyed arms emit DSL.row(Record) env.getSource(.get(table.col), …​); RecordKeyed / MappedRecordKeyed arms emit Record) env.getSource(.into(table.col, …​). buildKeyExtractionWithNullCheck stays RowKeyed-only with a defensive IAE on misroute (single-cardinality @splitQuery on a @table parent is the only caller). buildFkRowKey (RecordParentBatchKey RowKeyed) reads scalar values per parent ResultType (jOOQ TableRecord, jOOQ Record, Java record getter, typed POJO getter) and constructs the RowN<…​> via DSL.row(…​). New buildAccessorKeySingle / buildAccessorKeyMany emit _elt.into(table.col1, …​) to produce RecordN<…​> keys, giving the auto-emitted rows-method’s value<N>() access for the parent VALUES table emission. Parent VALUES emission. SplitRowsMethodEmitter forks two ways: RowN-keyed arms (RowKeyed, LifterRowKeyed) use k.field<N>() (returns the inline-value Field a DSL.row(value, …​)-constructed Row carries); RecordN-keyed accessor arms use DSL.val(k.value<N>()) (extract the scalar; wrap as a bind-parameter Field that typechecks against the inline-i first arg of jOOQ’s DSL.row overload). Without the DSL.val wrap the column-reference Field rendered into the VALUES table at runtime instead of the value. Lift Invariant #10 (bfeae318): the validator’s single-cardinality RecordTableField / RecordLookupTableField rejection (validateRecordParentSingleCardinalityRejected) was a downstream gate stranded by the rows-method router pinning the single-record-per-key arm to AccessorKeyedMany only. The data-fetcher side (buildRecordBasedDataFetcher) already handled single cardinality cleanly via the (dispatch == LOAD_MANY || !isList) → Record valueType rule, so the only missing wiring was teaching emitsSingleRecordPerKey() to also be true for single-cardinality fields. RecordTableField.emitsSingleRecordPerKey() extends to !returnType().wrapper().isList() || batchKey() instanceof AccessorKeyedMany, RecordLookupTableField adds the missing override mirror, and the validator gate drops. @DependsOnClassifierCheck annotations. Two checks under buildAccessorKeySingle / Many describing the Field-typed into(…​) projection (accessor-rowkey-shape-resolved from R60); paired with @LoadBearingClassifierCheck on FieldBuilder.deriveBatchKeyFromTypedAccessor. Tests. L1 BatchKeyTest parameterised case pins keyElementType() and javaTypeName() per variant: RowKeyed, MappedRowKeyed, LifterRowKeyedRowN<…​>; RecordKeyed, MappedRecordKeyed, AccessorKeyedSingle, AccessorKeyedManyRecordN<…​>. L3 ServiceFieldValidationTest adds dual-shape cells (MappedRowKeyed / MappedRecordKeyed both classify cleanly on the same field). L3 RecordTableFieldValidationTest / RecordLookupTableFieldValidationTest flip the three SINGLE* cases from rejection to acceptance. L3 GraphitronSchemaBuilderTest adds RECORD_TABLE_FIELD_SINGLE_CARDINALITY pinning post-R61 acceptance + emitsSingleRecordPerKey() projection. L4 TypeFetcherGeneratorTest.serviceField_mappedRecord_list_keyTypeIsRecordN pins the Set<Record1<Integer>> parameter shape and record.into(…​) extraction. L5 TestServiceStub.java keeps both Row1-source and Record1-source fixtures (getFilmsWithSetOfRow1Sources / getFilmsWithSetOfRecord1Sources siblings) as the dual-shape coverage anchor. L6 FilmService.titleUppercase(Set<Record1<Integer>>) → Map<Record1<Integer>, String> confirms value1() works in the developer-side iteration; the existing Row1-source sibling confirms field1()-based dispatch keeps working. The AccessorKeyedSingle execution path is restored end-to-end against PostgreSQL (FilmCardData(FilmRecord film) + film: Film). Out of scope, deferred: @batchKeyLifter lifter return-type symmetry (the consumer-supplied static method still pinned to org.jooq.Row1..Row22) ; owned by R71. Element-shape conversion for Set<TableRecord> / List<TableRecord> developer signatures ; closed by R70 by extending the variant taxonomy rather than threading conversion through the emitter. Open question closed in this iteration: "does Row1 afford a tuple-IN planner hint that Record1 may not?" Resolution: no. jOOQ’s Record1<T> extends Row1<T>, so every typed Row1-API call site continues to type-check when handed a Record1<T>; framework WHERE-clause emission reads keys via Row-typed APIs and is shape-agnostic. Approval addendum: post-landing drift survives intact (this approval pass on 2026-05-09, ~5 days after In Review). The variant-identity-tracks-shape contract was the foundation two follow-on items explicitly built on. R70 (ea44908f) added TableRecordKeyed / MappedTableRecordKeyed permits to extend the cross-product to a third element shape (developer’s typed TableRecord subtype); keyElementType()’s switch grew two cases, `buildKeyExtraction grew a third arm emitting Record) env.getSource(.into(Tables.X). R110 (75379091 + 3992f51 + 8922092 + 3b7f432) replaced @batchKeyLifter with @sourceRow and split LifterRowKeyed into LifterLeafKeyed + LifterPathKeyed under a new LifterKeyed sub-seal of RecordParentBatchKey. R61’s row-vs-record symmetry survived: the lifter arms still produce RowN, the accessor arms still produce RecordN, and the deferred R71 surface (lifter return-type symmetry) remains the only consumer-supplied surface without shape-symmetry. R102, R77, R82, R78, R104, R114 all touched the variant taxonomy or its consumers without disturbing R61’s invariants. Editorial drift in the spec at approval (not blocking, file deleted on Done): the spec body retained pre-R110 names (LifterRowKeyed, @batchKeyLifter) at three call sites; this is purely cosmetic on the now-deleted artifact, and the implementation reflects the post-R110 shape correctly. Build green: full mvn -f graphitron-rewrite/pom.xml install -Plocal-db on Java 25; current trunk passes the same test surface that landed at In Review.

  • Replace @batchKeyLifter with @sourceRow composing with @reference (sourcerow-replaces-batchkeylifter, R110, 7537909 + 3992f51 + 8922092 + 3b7f432): @batchKeyLifter is removed; @sourceRow(className, method) replaces it with flat args (no ExternalCodeReference wrapper, no targetColumns) and composes with @reference so multi-hop paths from a non-table-backed @record parent become expressible. Model. BatchKey.LifterRowKeyed splits into LifterLeafKeyed(JoinStep.LiftedHop hop, LifterRef lifter) (no-@reference leaf-PK arm) and LifterPathKeyed(List<JoinStep> path, LifterRef lifter) (@reference-composed FK chain) under a new BatchKey.LifterKeyed sub-seal of RecordParentBatchKey. LifterPathKeyed’s compact constructor enforces non-empty path. The seal is honest about what it carries today ; the resolver’s typed return narrows to `LifterKeyed, and GeneratorUtils.buildRecordParentKeyExtraction’s exhaustive switch collapses both permits onto a single `case BatchKey.LifterKeyed lk arm because the lifter emit shape (Lifter.methodBacking) env.getSource() is identical for both ; and what it doesn’t (the SplitRowsMethodEmitter prelude consumes both shapes via the JoinStep.WithTarget capability and RecordParentBatchKey.preludeKeyColumns(), not a LifterKeyed-typed parameter, so the capability-uniformity claim is future-facing rather than load-bearing in production). Resolver. New SourceRowDirectiveResolver with sealed builder-internal Derivation.{Leaf | Path} typing the parent-side tuple source: Leaf reads the leaf target’s PK columns directly (single column-equality JOIN); Path delegates to BuildContext.parsePath(startSqlTableName=null) and takes the first FK hop’s source-side columns. Two diagnostic templates distinguish the two cases (per-position prose names "first-hop source-side column of FK '<fk>'" vs "primary key column '<col>' of '<leaf>'"). @reference parse failures surface directly without re-validating against the lifter. Reflection performs class load, single-static-method discovery, parameter-assignability check against the parent backing class, and org.jooq.Row1..Row22 raw-return + arity-bounds checks before the per-position erasure loop. Three classifier-check keys (@LoadBearingClassifierCheck on the resolver, paired with @DependsOnClassifierCheck on consumers): sourcerow-classifies-as-record-table-field (resolver always projects into RecordTableField or RecordLookupTableField, paired with SplitRowsMethodEmitter.emitParentInputAndFkChain); sourcerow-leafkey-batchkey-is-lifterleafkeyed and sourcerow-pathkey-batchkey-is-lifterpathkeyed (the no-reference vs reference-composed permit guarantee, both paired with GeneratorUtils.buildLifterRowKey). Two keys not one because the relaxation surface is independent: a future variant could allow LifterKeyed outside the leaf-PK / path-keyed split without affecting the other guarantee. LoadBearingGuaranteeAuditTest picks up all three pairs automatically. Tests. L1 unit BatchKeyTest adds lifterPathKeyed_emptyPath_throwsIllegalArgument (compact-constructor invariant) and extends recordParentBatchKeyExhaustiveSwitchCompilesAcrossPermits to verify the sub-seal compiles to a single LifterKeyed arm. L4 pipeline GraphitronSchemaBuilderTest.SourceRowClassificationCase (renamed from BatchKeyLifterCase) carries 16 cases covering: pojo + Row1<Integer> + @reference happy path; lookup-key co-presence; null-fqClassName / @table / jOOQ-record parent rejects; JavaRecordType admit; missing class / missing method / multiple matches / wrong return / wrong param type rejects; arity / column-class / wildcard mismatches; @reference parse failure passthrough; @asConnection reject; @field(name:) non-interaction; field-level @condition co-presence; @orderBy co-presence; scalar return reject; LEAF_PK_NO_REFERENCE (the new no-@reference arm produces LifterLeafKeyed); LEAF_PK_ARITY_MISMATCH (leaf-PK diagnostic distinguishes from path-keyed). All 1465 graphitron tests pass. Sakila fixtures. CreateFilmPayload.language migrated to leaf-PK (@sourceRow alone, no @reference). New Story 1 fixture: Query.customerAddressSummary(customerId: Int!): CustomerAddressSummary whose address field carries @sourceRow + @reference(path: [{key: "customer_address_id_fkey"}]) ; the canonical path-keyed shape. Backed by CustomerAddressSummary Java record, CustomerAddressSummaryLifter.addressIdOf(parent) → Row1<Integer>, and CustomerAddressSummaryService. End-to-end coverage flows through the L5 compile-spec tier on graphitron-sakila-example. Documentation. New how-to docs/manual/how-to/source-row.adoc (leaf-PK + path-keyed shapes with full SDL + Java + rejection-message anchors). Renamed reference directives/batchKeyLifter.adocdirectives/sourceRow.adoc rewritten for the flat-args directive. Sweep across external-code.adoc (drops @sourceRow from the ExternalCodeReference table; cross-link to the dedicated how-to), result-types.adoc, record.adoc, notGenerated.adoc, condition.adoc, handle-services.adoc, and the reference / how-to indexes. Internal rewrite-design-principles.adoc updated for the sub-seal and renamed classifier keys. Architect-review revisions (3b7f432) tightened the LifterKeyed Javadoc to describe today vs. tomorrow without overclaiming, dropped uninstall AsciiDoc tag markers from schema.graphqls (the docs build is plain Asciidoctor with no example resolver, so tag::sourcerow-leafpk[] / tag::sourcerow-story-1[] had no consumer), and filed R116 (composite-key-row2-source-row-coverage) for the composite-key Row2 path-keyed coverage gap (the resolver admits Row2..Row22 today; the gap is in the test catalog, no 2-column FK exists). Findings noted at approval (non-blocking, follow-ups not yet filed): (1) the LSP module’s hand-written directive registry was not updated for R110 ; graphitron-lsp/…​/parsing/DirectiveDefinitions.java:77-80 still defines batchKeyLifter with the obsolete lifter: ExternalCodeReference + targetColumns: [String!]! shape (no sourceRow entry), …​/diagnostics/Diagnostics.java:45 lists "batchKeyLifter" not "sourceRow" in VALIDATE_METHOD, and three test files (DirectiveDefinitionsTest, DiagnosticsTest, ClassNameCompletionsTest) pin @batchKeyLifter as part of the registry surface. The build is green because the LSP tests are self-consistent against their own hand-written registry, but the registry has drifted from directives.graphqls so an IDE consumer will surface "unknown directive" diagnostics on @sourceRow and continue to suggest a removed @batchKeyLifter. The flat-args shape may require extending DirectiveDef/InputTypeBinding to express non-ExternalCodeReference arg shapes, so this is a small standalone follow-up rather than a rework gate; the spec body framed migration as "internal-only" because adoption was minimal, but the LSP is a user-facing consumer of the directive surface that the spec didn’t enumerate. (2) docs/manual/reference/diagnostics-glossary.adoc:103 (the lifter-method entry) describes the wrong lifter shape: it says “methodName:” (the actual arg is method:) and “(Set<Key>) → Map<Key, Value>” (that’s the @service rows-method shape; @sourceRow’s lifter is `(parent backing class) → RowN<…​> per the resolver and the howto). Looks like the entry was copy-pasted from attempt-service-method (line 94) without retargeting. Both findings are tractable as small standalone Backlog items; neither blocks the architectural surface (sealed sub-seal, three classifier-check keys, single-arm switch collapse) which is sound. Build green: full mvn -f graphitron-rewrite/pom.xml install -Plocal-db on Java 25.

  • Demote @asConnection + same-table @nodeId guard from rejection to advisory warn (narrow-asconnection-same-table-nodeid-guard, R113, afedb4b8 + 846f055d + e5358818 + 7d59ffd5): R106 lifted same-table @nodeId args from a QueryLookupTableField lookup to a QueryTableField + BodyParam.In filter on the connection rail; one inherited rejection from the lookup era did not compose. R113 first pass narrowed the rejection to the conjunctive ∃ required same-table @nodeId leaf predicate (pathRequired = outer arg && every nested input wrapper non-null) and collapsed the carrier into a sealed AsConnectionGuard.{None | Required(SameTableHit)}. Production schema (opptak-subgraph’s Query.kompetanseregelverkGittIdV2(ider: [ID!]! @nodeId(typeName: "Kompetanseregelverk")): [Kompetanseregelverk!] @asConnection) deliberately composes that shape to ship a paginated WHERE pk IN (decoded_ids) connection to consumers; the rejection blocked a wire format the producer authored on purpose. Rework demoted the rejection arm to a LOG.warn at FieldBuilder.resolveTableFieldComponents; classification falls through to QueryTableField + FieldWrapper.Connection and the connection emitter ships the expected SQL. With the build break gone the sealed two-arm carrier collapsed further to a single nullable SameTableHit firstRequiredSameTableHit field on NodeIdArgPlan (architect-review tightening ; sealed sub-taxonomy was justified to gate a rejection, not a single warn site). The warn routes through ASCONNECTION_HYGIENE_LOG = LoggerFactory.getLogger(FieldBuilder.class.getName() + ".asConnectionSameTableHygiene"), mirroring the BuildContext.idRefShim precedent: stable category address for log filters and migration tooling, independent of FieldBuilder class organisation. formatAsConnectionSameTableRejection renamed to formatAsConnectionSameTableWarning with advisory rather than directive prose; still names field/leaf/typeName for migration tooling to grep on. Conjunctive ∃-required walk and cycle-protection scoping (add on entry, remove on return so sibling subtrees sharing an input-type subgraph each get visited independently) unchanged from the first pass. Tests: pipeline-tier NodeIdPipelineTest.NodeIdConnectionAdvisoryCase (8 cases, all _ALLOWED; required arg/input field/conjunctive cases assert QueryTableField + FieldWrapper.Connection + BodyParam.In on PK + pagination components, structurally identical to the optional cases R106 already shipped; the carrier flip from rejected→allowed is visible as a rename + assertion-shape change). Unit-tier AsConnectionSameTableWarnFormatTest (one requiredLeaf_emitsWarn_namingFieldLeafAndType case via logback ListAppender on the category logger; pins field/leaf/typeName + every page of @asConnection would equal the input set headline + make 'ids' nullable advisory hint ; the stable bits migration tooling can grep on). Execution-tier GraphQLQueryTest.filmsConnectionByRequiredIds_idsSupplied_paginatesBoundedSet mirrors the production shape (required outer wrapper on a same-table @nodeId list arg composed with @asConnection): three ids supplied with first: 2 returns 2 with hasNextPage=true; page 2 after the cursor returns the remaining 1 with hasNextPage=false. Out of scope: directive-based warn suppression (three silencing routes already exist ; nullable leaf, drop @asConnection, FK-target arg); @LoadBearingClassifierCheck annotation (architect-review verified hygiene-only ; annotating would be inert); FK-target @nodeId + @asConnection (composes today via Resolved.FkTarget.DirectFkBodyParam.In/Eq/RowIn/RowEq); implicit scalar-ID-arg path (synthesised, not authored); element-level nullability inside an outer-required list (the list is bounded once the outer wrapper is non-null). Editorial follow-up noted at approval (not blocking): stale Javadoc at FieldBuilder.java:258 references NodeIdArgPlan.AsConnectionGuard.Required and "rejection message" ; both removed by the carrier collapse. Build green: full mvn -f graphitron-rewrite/pom.xml install -Plocal-db on Java 25.

  • Multi-hop @reference path on @nodeId filter input fields, identity-carrying lift (multi-hop-nodeid-reference-filter, R114, b80594ff + 0efba89f + 0d12cc1): NodeIdLeafResolver.resolveFkJoinPath now accepts @reference(path: […​]) of length ≥ 2 on @nodeId(typeName: T) filter input fields and arguments when every step is a JoinStep.FkJoin and every adjacent pair satisfies the lift predicate (each step’s source-side columns are a positional subset of the previous hop’s target-side columns by SQL name). The terminal hop’s source-side tuple lifts back through the chain to a sub-tuple of the first hop’s source-side columns, on the parent’s own table, positionally aligned with the decoded NodeType keys. The DirectFk vs TranslatedFk decision switches from joinPath.get(0) to joinPath.getLast(); the resolver’s Resolved.FkTarget.DirectFk gains a liftedSourceColumns: List<ColumnRef> slot and the four reference carriers (InputField.{Column,CompositeColumn}ReferenceField, ArgumentRef.ScalarArg.{Column,CompositeColumn}ReferenceArg) gain the matching slot, populated at carrier construction. Emitters at FieldBuilder.projectFilters and FieldBuilder.walkInputFieldConditions swap JoinStep.FkJoin) joinPath().get(0.sourceSideColumns() for liftedSourceColumns() read from the carrier; the emitted SQL is the same direct row predicate (field.eq/in(…​) for arity 1, DSL.row(…​).eq/.in(…​) for arity ≥ 2) single-hop direct-FK already produces. Chain length is purely a classifier-time concept; the runtime touches one table, no JOIN, no subquery. Multi-hop is always explicit: the auto-discovery fallback (JooqCatalog.findUniqueFkToTable) stays single-hop only, so disambiguation among A → ? → C chains is the author’s responsibility via per-hop { key: …​ }. Two distinct @LoadBearingClassifierCheck keys (not one widened key): nodeid-fk.direct-fk-keys-match widens to "the terminal hop’s target-side columns positionally match NodeType key columns"; new nodeid-fk.identity-carrying-lift covers "every intermediate hop satisfies the lift predicate so the lifted tuple is well-defined and lives on the parent’s own table". Independent invariants for independent future relaxations. Diagnostics are anchored on static final String LIFT_FAILURE_MARKER = "identity-carrying FKs" and CONDITION_STEP_MARKER = "must be a foreign key" constants on NodeIdLeafResolver; tests assert against the constants by name rather than copying prose. Wider migration of R57’s substring-based assertions to constant markers filed as a sibling. Tests: unit-tier NodeIdLeafResolverTest (3 new cases ; multiHopIdentityCarryingLift_succeeds pins the lifted tuple shape on the level_a/b/c chain, multiHopLiftTranslationRejected anchors on LIFT_FAILURE_MARKER, multiHopConditionStepRejected anchors on CONDITION_STEP_MARKER); pipeline-tier NodeIdPipelineTest.{ArgumentFkTargetNodeIdCase.MULTI_HOP_IDENTITY_CARRYING, InputFieldFkTargetNodeIdCase.MULTI_HOP_IDENTITY_CARRYING_INPUT} pin the carrier-side identity (joinPath.size() == 2, BodyParam.RowIn.columns() SQL names = (k1, k2), decode method = decodeLevelA); pipeline-tier QueryConditionsPipelineTest.multiHopIdentityCarryingLift_emitsHelperOnLiftedTuple pins helper-method emission (body-string assertions banned per the test-tier rules; the L3 BodyParam-level case pins the SQL-shape lift). Compilation-tier coverage rides on mvn install -Plocal-db’s `graphitron-sakila-example compile (the lifted-tuple type aligns with decode<TypeName> helper signatures). New nodeidfixture chain: level_a (PK (k1, k2)), level_b (PK (s, k1, k2), FK to level_a on (k1, k2)), level_c (PK (c, s, k1, k2), FK to level_b on (s, k1, k2)), plus lift_fail_{a,b,c} for the translation-failure case; both metadata-registered in NodeIdFixtureGenerator. Howto article at docs/manual/how-to/multi-hop-nodeid-filter.adoc (mental-model first, worked example, two rejection-message sections anchored on the marker constants); SDL is inlined inside the article because the nodeidfixture jOOQ classes live in a separate package from the sakila-example’s jooqPackage (tag:: switch lands with the L6 wiring follow-on). Honest deviations carried forward: the L6 execution-tier round-trip (GraphQLQueryTest.multiHopReferenceFilter_returnsRows, asserting "single-table FROM, no subquery" via ExecuteListener) is deferred to a Backlog sibling because wiring nodeidfixture.level_* into the example needs either a second graphitron-codegen execution or duplicated tables under the sakila-example’s public schema, neither of which is in scope for the carrier-shape change R114 owns. The same precedent already applied to R50’s parent_node/child_ref fixture. Out of scope, follow-ups filed: non-identity-carrying multi-hop @reference on @nodeId (EXISTS-subquery / JOIN-with-translation emission, symmetric to R57’s single-hop translated FK case); rename column / columns slot on the four reference carriers to a role-explicit name (the slot holds NodeType key columns on the target table but reads as "the predicate column"); diagnostic-anchoring policy migration of R57’s substring-based assertions; L6 execution-tier round-trip; Resolved.FkTarget.DirectFk.fkSourceColumns vestigial slot (now fully covered by liftedSourceColumns). Build green: full mvn -f graphitron-rewrite/pom.xml install -Plocal-db on Java 25; LoadBearingGuaranteeAuditTest picks up the new key’s producer/consumer pairs automatically.

  • Make graphitron-maven-plugin IT self-contained via extraArtifacts (maven-invoker-it-extra-artifacts, R111, 11f276184): the two ITs under graphitron-maven-plugin/src/it/ (basic-generate, missing-schema-inputs) failed in Rewrite reactor CI because the forked child Maven could not resolve no.sikt:graphitron-sakila-db:10-SNAPSHOT; CI runs verify, which never installs sibling reactor modules into ~/.m2, and invoker:install only seeds the IT local-repo with the project under test plus its declared dependency tree. Add <extraArtifacts><extraArtifact>no.sikt:graphitron-sakila-db:${project.version}</extraArtifact></extraArtifacts> to the maven-invoker-plugin configuration in graphitron-maven-plugin/pom.xml so the IT’s missing sibling rides the same reactor-cache resolution invoker:install already uses, and rewrite the contract comment in src/it/settings.xml to name <extraArtifacts> as the seam for sibling-module IT deps so the next contributor adds an entry there rather than reintroducing an implicit mvn install prerequisite. Tests: no new IT ; adding a third invoker IT to lock the seam down would mean inventing a synthetic sibling-module dependency; the rewritten settings.xml comment serves the doc-of-record role, and the Rewrite reactor CI workflow is the regression rail. Verification: with the cached snapshot wiped (rm -rf ~/.m2/repository/no/sikt/graphitron-sakila-db), mvn -f graphitron-rewrite/pom.xml verify -Plocal-db --batch-mode runs both ITs to SUCCESS where they previously failed with Could not find artifact …graphitron-sakila-db:jar:10-SNAPSHOT. Out of scope: switching CI from verify to install (would mask the issue and pollute the runner cache); profile-gating the entry (unconditional and harmless either way); auditing other reactor modules for similar issues (graphitron-maven-plugin is the only IT-housing module today). Build green: full mvn -f graphitron-rewrite/pom.xml install -Plocal-db on Java 25.

  • @record-parent multi-table polymorphic ChildField classifier arm (record-parent-multitable-polymorphic-classifier-arm, R105, 76c3262f + 518ffc70 + c7c3579c + 21af55604): the ReturnTypeRef.PolymorphicReturnType arm of FieldBuilder.classifyChildFieldOnResultType is no longer a blanket Rejection.deferred("@record type returning a polymorphic type is not yet supported", ""). Three of the four BatchKey.RecordParentBatchKey permits become reachable on @record-backed parents: RowKeyed when the parent is a JooqTableRecordType (hub = parent’s mapped table); AccessorKeyedMany when the parent is a PojoResultType / JavaRecordType exposing a unique zero-arg List<X> / Set<X>-returning accessor for some concrete X extends TableRecord (hub = accessor’s element-Record table). AccessorKeyedSingle is structurally derivable but deferred at the classifier (Rejection.deferred("polymorphic-child-record-parent-single-cardinality")): MultiTablePolymorphicEmitter.buildScalarPerParentFetcher reads parent context as Record parentRecord = (Record) env.getSource() and has no @record-Pojo arm, so producing the permit there would generate code that ClassCastExceptions at runtime on a Pojo source. LifterRowKeyed for polymorphic returns stays deferred per Out of scope (@batchKeyLifter’s `targetTable derivation reads the field’s @table element type, which doesn’t apply to polymorphic returns). The hub TableRef is consumed at classification time (handed to resolveChildPolymorphicJoinPaths for per-participant FK auto-discovery) and never re-read after the field record is constructed, so it stays a classifier-internal local rather than a slot on the field record. New builder-internal sealed result PolymorphicRecordParentResolution.{Resolved(parentKey, hubTable) | Rejected(rejection)} per the principles' "Builder-step results are sealed" rule. New shared private helper collectAccessorMatches factored out of deriveBatchKeyFromTypedAccessor; deriveBatchKeyFromHubAccessor is the polymorphic-callsite sibling whose reduction step discovers the hub from the unique resolvable accessor rather than pinning against an external @table (none on a polymorphic return). The multitable-polymorphic-child.parent-key-extraction-is-batchkey-driven @LoadBearingClassifierCheck splits per-producer (…-table-backed on classifyObjectReturnChildField, …-record-parent on classifyChildFieldOnResultType) per the audit’s one-producer-per-key rule; the two MultiTablePolymorphicEmitter consumer call sites (buildBatchedConnectionFetcher, buildBatchedListFetcher) gain a second @DependsOnClassifierCheck for the new key via the repeatable annotation. New accessor-rowkey-shape-resolved-against-hub key on deriveBatchKeyFromHubAccessor: same-shape sibling of the existing accessor-rowkey-shape-resolved but the identity contract is hub discovery rather than expected-table match. validateChildMultiTableParentPk (GraphitronSchemaValidator.java:347) drops the TableBackedType-gated early-return and reads field.parentKey().preludeKeyColumns() uniformly across all four RecordParentBatchKey permits; signature loses parentTypeName, Map<String, GraphitronType> types and gains the field reference, with both call sites updated. The non-empty invariant moves entirely upstream: RowKeyed’s canonical constructor and `JoinStep.LiftedHop’s constructor both reject empty key columns at construction time, and the classifier routes empty-PK / unresolved-hub parents through `UnclassifiedField. Architect-review type-system tightening: ChildField.{InterfaceField, UnionField} canonical constructors enforce non-null parentKey and parentResultType via Objects.requireNonNull, lifting the validator’s de-facto contract into the type system. Tests: pipeline-tier RecordParentMultiTablePolymorphicPipelineTest (new file under graphitron/src/test/java/no/sikt/graphitron/rewrite/) drives the SDL → classifier path for all reachable permits ; childInterfaceField_recordParent_rowKeyed and _typeSpecEqualsTableBacked (parity-pin between the @record-JooqTableRecord producer and the table-backed producer via methodSpec.toString() comparison so any drift across the two construction sites fails fast), _accessorKeyedMany (hub identity off LiftedHop.targetTable(), LOAD_MANY dispatch), _accessorKeyedSingle_deferred (DEFERRED rejection assertion for the Pojo + single-cardinality shape), and recordParentPolymorphic_pojoWithoutMatchingAccessor_classifiesAsUnclassifiedField (three-option AUTHOR_ERROR with hub-author-error tail). UnionField siblings mirror the InterfaceField cases. Validator-tier InterfaceFieldValidationTest extends with rejects_listArm_onAccessorKeyedManyHubArityOver21 (22-column hub PK on AccessorKeyedMany trips the same Row22 cap as the table-backed RowKeyed path); the now-unreachable rejects_connection_onPkLessParent and rejects_listArm_onPkLessParent tests are dropped (empty-PK is unreachable through the canonical constructors). New resultTypeFor(table) test-fixture helper publishes a sentinel JooqTableRecordType for the type-system non-null contract. GraphitronSchemaBuilderTest.NON_ERROR_POLYMORPHIC_FALLS_THROUGH_TO_DEFERRED_REJECTION updated: the Pet union fixture is Pojo-parent + single-cardinality, exactly the new deferred shape, and now lands on DEFERRED rather than the prior AUTHOR_ERROR. Out of scope, follow-ups: widening MultiTablePolymorphicEmitter.buildScalarPerParentFetcher to consume parentKey + parentResultType analogously to the list arm (lifts the AccessorKeyedSingle defer); contract-on-the-field-record audit shape so consumers cite one key rather than the producer-key disjunction; per-participant constraint coverage beyond resolveChildPolymorphicJoinPaths; user-facing @record-with-polymorphic-children documentation. Build green: full mvn -f graphitron-rewrite/pom.xml install -Plocal-db on Java 25.

  • Lift same-table @nodeId arg/field to a WHERE pk IN (…​) filter, not a lookup (nodeid-lookup-ignores-filter-siblings, R106, 91c3cb892): same-typename @nodeId args on a table-bound query field now classify as QueryTableField with a BodyParam.In / BodyParam.RowIn predicate against the table’s primary key, instead of the implicit promotion to QueryLookupTableField. Sibling filter args (scalar @condition / @field) compose with the @nodeId arg as ordinary BodyParam predicates rather than being silently dropped under the legacy lookup-promotion gate. Three classifier-seam edits in FieldBuilder.java: the Resolved.SameTable arm reads arg.hasAppliedDirective(DIR_LOOKUP_KEY) instead of hard-coding isLookupKey = true (filter is the new default; explicit @lookupKey re-enables the N×M derived-table shape); the blanket @nodeId @lookupKey rejection is gone on the same-table arm and remains on the FK-target arm with a pointed message ("@lookupKey is meaningless on an FK-target @nodeId arg"); classifyQueryField’s lookup-promotion gate drops the `lookupPlan.anyArgSameTable() half and is now purely hasLookupKeyAnywhere(fieldDef). Pulls the same-table @nodeId path onto the same filter rail as FK-target @nodeId (Resolved.FkTarget.DirectFk already lifts to BodyParam.In/Eq/RowIn/RowEq), collapsing two near-identical paths into one and making mixed-shape inputs first-class. Tests: pipeline-tier NodeIdPipelineTest.ArgumentSameTableNodeIdCase migrates 4 cases from QueryLookupTableField / ScalarLookupArg / DecodedRecord to QueryTableField / BodyParam.In / BodyParam.RowIn assertions; new SAME_TABLE_WITH_FILTER_SIBLING case pins the headline composed-with-sibling lift on a composite-PK NodeType; new SAME_TABLE_WITH_EXPLICIT_LOOKUP_KEY case pins that explicit @lookupKey re-enables the lookup shape; new FK_TARGET_LOOKUP_KEY_REJECTED case pins the new FK-target rejection. Execution-tier GraphQLQueryTest.filmsByNodeIdArgWithTitleFilter_composesPkInWithSiblingFilter exercises the lift end-to-end (PK-IN composed with WHERE title = ?); the existing films_filteredByArgNodeId_returnsRowsMatchingDecodedIds test stayed green with its comment refreshed for the new shape. No @LoadBearingClassifierCheck keys touched (audit-inert flip). Out of scope, follow-ups: collapsing NodeIdArgPlan.{anyArgSameTable, anyNestedSameTable, sameTableHit} into a sealed AsConnectionGuard.{None | Hit} carrier (the @asConnection rejection at FieldBuilder.java:403-407 is the only remaining consumer; clean follow-up); the implicit @lookupKey directive walker inputTypeHasLookupKey; FK-target @nodeId (already a filter, no behavior change). Editorial follow-ups noted at approval (not blocking): stale comments at FieldBuilder.java:1105 ("the same-table arm synthesises isLookupKey: true") and BuildContext.java:181 ("same-table lookup vs FK-target filter") describe pre-R106 behavior and want a refresh. Build green: full mvn -f graphitron-rewrite/pom.xml install -Plocal-db on Java 25.

  • RC parity audit: classify GraphitronField/Type leaves and ship coverage gaps (rc-parity-audit-leaf-coverage, R104, Phase 1a/1b 14386cfc8, Phase 1a tests 8ddfb272f, Phase 1c 5bab9ca7f, Phase 1d a032c96f3, Phase 2 e0ff1f923, Phase 3 c1fcbdcb2, Phase 4 ba8149a7f, self-review fixes b947098e8, README link acbcd4799, docs-site staging adc63948a, rework 32e10769): funnel every classifier write through named operations that emit a JSONL trace, then ship a DuckDB-backed post-processor that joins the per-module traces with the sealed-permits inventory and roadmap mentions to produce a regenerable per-leaf coverage report and a consumer-facing migration-fragment. BuildContext’s previously bare `types map and GraphitronSchemaBuilder.buildSchema’s bare `fields map become TypeRegistry / FieldRegistry private fields; type and output-field writes route through classify / enrich / demote / synthesize (each carrying a clean prior-entry precondition); the input-field path routes through FieldRegistry.classifyInput (trace-only ; input fields are embedded in their parent type, not a central map, and the asymmetry is documented honestly rather than fought). ClassificationTrace is gated on -Dgraphitron.classification.trace=<path>; the new parent-pom -Pleaf-coverage profile sets the property to ${project.build.directory}/leaf-coverage.jsonl per module, with a maven-antrun truncate before the test phase so re-runs don’t append on top of stale records. A JUnit 5 extension auto-registered via META-INF/services plus junit.jupiter.extensions.autodetection.enabled=true tags every record produced inside a test’s lifecycle with the running test class and its tier annotation (resolved through the meta-@Tag on @UnitTier / @PipelineTier / @CompilationTier / @ExecutionTier, with @Tag("cross-cutting") exempted into a separate report column). roadmap-tool leaf-coverage opens an in-memory DuckDB connection, exposes the per-module JSONL files as a view via read_json_auto('graphitron-rewrite//target/leaf-coverage.jsonl', union_by_name=true), stages parsed leaves (sealed permits + javadoc intent) and mentions (roadmap simple-name grep) tables, and renders both the internal report at graphitron-rewrite/roadmap/inference-axis-coverage.adoc and a consumer-facing --mode=migration AsciiDoc fragment. directive-support gains a sibling --mode=migration render. The migration guide at docs/manual/how-to/migrating-from-legacy.adoc include::`s both fragments under "Authoritative supported surface". Verify-mode of `roadmap-tool is bound to the verify phase and fails CI when the README or the leaf-coverage report drift; the CI workflow now runs mvn verify -Plocal-db -Pleaf-coverage so the trace files exist for the verify check. *Tests: unit-tier TypeRegistryTest (8) and FieldRegistryTest (5) pin precondition contracts; ClassificationTraceTest (5) documents the JSONL framing including ThreadLocal context inheritance and JSON escape; ClassificationTraceContextExtension auto-registers and tags every existing test’s classification records with its tier; LeafCoverageReportTest (5) covers parser hierarchy isolation, intent attribution, the nested-record-vs-sealed-parent worked case (MutationField / DmlTableField), and the roadmap-mention join; DirectiveSupportReportTest extension covers the --mode=migration render. Honest deviations carried forward: the directive-support migration fragment has no verify-mode CI binding in roadmap-tool’s verify phase because that fragment reads graphitron-common/src/main/resources/directives.graphqls (a legacy module the rewrite reactor explicitly does not resolve); it regenerates from the docs build instead. Follow-up filed: R107 captures LeafCoverageReport.parseMentions’s simple-name join sensitivity (any roadmap edit that names a leaf class drifts the report). Out of scope, owned by the triage follow-up*: classifying each leaf as Covered / Trivial gap / RC-blocker / Defer and spawning sibling Backlog items per RC-blocker; this item ships the regenerable data the triage will read from. Build green: full `mvn -f graphitron-rewrite/pom.xml install -Plocal-db -Pleaf-coverage on Java 25.

  • Extract ConnectionPromoter from GraphitronSchemaBuilder (extract-connection-promoter, R56, 3f1c9af9 + e94a1a9a): the ~250-line Connection-promotion concern (turning @asConnection carrier fields into proper Connection-typed fields, plus synthesising Connection / Edge / PageInfo entries on ctx.typeRegistry) lifts into a final package-private sibling under graphitron/src/main/java/…​/rewrite/. GraphitronSchemaBuilder.java shrinks 670 → 288 lines (well past the spec’s ≤440-line target); the orchestrator retains the two-call sequence (ConnectionPromoter.promote(ctx)ConnectionPromoter.rebuildAssembledForConnections(…​)) but the implementation moves. Pure structural extract-class ; no behaviour change, no sealed Resolved (the spec carved out why: this is a single-step structural transformation with no rejection arms; rejection of malformed @asConnection usage already lives upstream in FieldBuilder.classifyField). The local baseTypeName(GraphQLOutputType) helper migrates as a private static on the new class rather than being reconciled against BuildContext.baseTypeName(GraphQLFieldDefinition) (different signatures, different unwrap semantics; consolidation is a separate decision if it ever matters). Tests: new ConnectionPromoterTest (@UnitTier) exercises promotion directly via the existing GraphitronSchemaBuilder.buildContextForTests seam (the R40 test-only entry point that runs the schema generator + TypeBuilder but stops before field classification) ; eight focused-unit cases (directive-driven bare-list carrier, explicit connectionName:, explicit defaultFirstValue:, structural Connection-typed return enrich-path, SDL-declared @shareable PageInfo flag preservation, two-carrier dedup, return-type already names the Connection emits-no-rewrite, item-nullability propagation) plus a noSynthesisedTypes short-circuit regression on rebuildAssembledForConnections. Existing pipeline-tier coverage (GraphitronSchemaBuilderTest @asConnection cases, ConnectionRegistrationsTest, ConnectionTypeValidationTest) stays green as regression. The DIR_AS_CONNECTION directive-presence assertion in validateDirectiveSchema stays put (out of scope by spec). Build green: full mvn -f graphitron-rewrite/pom.xml install -Plocal-db on Java 25.

  • Batched key extraction for ChildField.UnionField / ChildField.InterfaceField via BatchKey (batch-multitable-polymorphic-child-fetcher, R102, Phases A-D f50f80b1, Phase E d42eeebf, scalar-fetcher cleanup e1e45122, rework 13eefedb, javadoc cleanup at approval): the multi-table polymorphic emitter no longer bypasses GeneratorUtils.buildRecordParentKeyExtraction; both arms (list and connection) read parentKey: BatchKey.RecordParentBatchKey and parentResultType: GraphitronType.ResultType off the field record and delegate to the canonical four-shape × four-permit key-extraction helper. The list arm gains DataLoader-batched fetch (one stage-1 UNION ALL with JOIN parentInput plus one per-typename SELECT, regardless of parent count); AddressOccupantsListBatchingTest pins the canonical sakila customer fanout to exactly 4 statements (down from ~14 pre-R102). BatchKey permits with key-column components now enforce non-emptiness via compact canonical constructors (six direct + three via JoinStep.LiftedHop’s slot-list invariant); `containerType’s dead empty-list fallbacks at `BatchKey.java:124, 133 removed. validateChildConnectionParentPk renamed to validateChildMultiTableParentPk, drops the Connection short-circuit, fires uniform N=21 cap on both arms (the shared parentInput VALUES emitter widens to Row<N+1>, topping out at jOOQ’s Row22); the codegen-time IllegalStateException at MultiTablePolymorphicEmitter.java:682-693 is removed in favor of the validator rejection. New multitable-polymorphic-child.parent-key-extraction-is-batchkey-driven @LoadBearingClassifierCheck@DependsOnClassifierCheck pair on FieldBuilder.classifyObjectReturnChildField (producer) and the two batched fetcher entry points (consumer). parentTable parameter dropped from every MultiTablePolymorphicEmitter helper signature; the dispatch site at TypeFetcherGenerator.java:436-461 collapses accordingly. Tests: audit-tier BatchKeyTest (parameterised non-empty case per permit + JoinStep.LiftedHop); pipeline-tier TypeFetcherGeneratorTest (list-form DataLoader registration, key-tuple arity for single-PK and composite-PK parents, Interface/Union equivalence pin, buildRecordParentKeyExtraction delegation pin); InterfaceFieldValidationTest and UnionFieldValidationTest mirror empty-PK and >21 arity rejections plus 21-boundary well-formed cases on both list and connection arms; execution-tier AddressOccupantsListBatchingTest against Address.occupants: [AddressOccupant!]!. Spec deviations carried forward: lifting the connection-rows participant single-PK truncation at MultiTablePolymorphicEmitter.java:824 into the validator deferred (existing Query.pagedItems → PagedA/PagedB with composite (k1, k2) PK quietly works on the truncation; promoting it to a hard error would block landing). The wrapper parameter for gating that check on Connection re-lands when the lift does. Out of scope, owned by R105: @record-parent classifier arm; lighting up the LifterRowKeyed / AccessorKeyedSingle / AccessorKeyedMany permits at classification time. R102 ships RowKeyed-only; the slot type accepts the full four-permit RecordParentBatchKey sub-seal so R105 wires that arm in without re-touching the records or the emitter. Build green: graphitron module 1434/1434 on Java 25.

  • @record accessor resolution validated at classify time (record-accessor-validation, R88, 0bcb6ebe + b2d798a9 + e5f2dd2e + 863d90c5): downstream consumers got cannot find symbol: getSakId() from javac on generated fetcher classes when the SDL field name didn’t match an accessor on the @record-backed POJO/Java-record (FetcherEmitter.propertyOrRecordValue synthesised the getter name purely from "get" + capitalize(toCamelCase(columnName)) without consulting the backing class). The fix lifts accessor resolution into the classifier per Classifier guarantees shape emitter assumptions and validator mirrors classifier invariants: new ClassAccessorResolver.resolve runs reflective lookup at Class.forName boundary (added to the reflection-roster at docs/rewrite-design-principles.adoc:29), returning sealed AccessorResolution.{Resolved | Rejected} with Resolved directly sealed over GetterPrefixed(Method) | BareName(Method) | FieldRead(Field). Resolution rules track graphql-java’s PropertyDataFetcher lookup order (get<CamelName>is<CamelName> for boolean → bare <camelName> → public field), enforce return-type assignability against the SDL field’s resolved Java type, and match either a single DataFetchingEnvironment parameter or per-arg parameters whose types match the SDL arg list. Phase E’s type-system tightening narrows the slot on PropertyField.accessor / RecordField.accessor from AccessorResolution to AccessorResolution.Resolved (still nullable for parents that don’t run reflective resolution: jOOQ-record-backed and null-fqClassName PropertyDataFetcher-fallback parents); FieldBuilder routes Rejected through UnclassifiedField carrying a new Rejection.AuthorError.AccessorMismatch arm whose message() appends the @field(name: "…") override hint inside the typed arm so discrimination rides on the seal rather than a message prefix; FetcherEmitter switches exhaustively over the three Resolved arms with no runtime fallback. The class-accessor-resolver-shape-guarantee @LoadBearingClassifierCheck@DependsOnClassifierCheck pair documents the contract; the consumer’s reliesOn text now describes a static type guarantee rather than a runtime invariant. Tests: pipeline-tier RecordFieldAccessorValidationTest (10 cases ; three rejection arms each pinning the UnclassifiedField routing structurally + producing actionable validator diagnostics; six positive arms exercising GetterPrefixed, BareName, FieldRead, full-env injection, per-arg injection, override-via-@field(name:)); execution-tier RecordExampleType fixture (all three fields resolve to Resolved, exercising the emit / execute boundary); audit-tier LoadBearingGuaranteeAuditTest picks up the producer/consumer pair automatically. Implementation deviation: per-arg injection at the emitter uses Method.getParameters() for argument names, requiring the backing class to be compiled with -parameters; methodCallExpr throws at emit time with a clear error if absent rather than producing silently-broken code. Out of scope, filed as follow-ups: input-side ctor/setter validation for @record-mapped input types; PropertyDataFetcher fallback removal at the null-fqClassName arm; Lombok / explicit-named accessor extensions; Levenshtein "did you mean" candidates. Build green: full mvn -f graphitron-rewrite/pom.xml install -Plocal-db across all 11 reactor modules on Java 25 (modulo two pre-existing unrelated trunk failures last touched by R79/R82).

  • LSP quick-fix and directive-vocabulary registry for the ExternalCodeReference name → className migration (lsp-externalcodereference-name-migration, R93, Phase 1 19e18b23, Phase 2 5258d16f, Cycle 2 75bee87b): two consumer-facing landings on the LSP. Phase 1 introduced DirectiveDefinitions as the LSP’s directive-vocabulary registry (keyed on directive name; each entry carries (argName, inputType, nestedPath) tuples) and migrated ClassNameCompletions.outerArgOf’s hardcoded three-directive lookup onto the derived view `argsByInputType("ExternalCodeReference"). The five sites that appeared in directives.graphqls but were unwired in the LSP today (@externalField, @enum, @tableMethod, @batchKeyLifter, and the nested ReferenceElement.condition inside @reference(path:)) gained completion + diagnostic surface as a side effect. Phase 2 added the code-action surface: SdlAction primitive (named Detector/Rewrite interfaces, sealed RewriteResult.{Edit | Skip} so the bulk action’s count-by-reason pivot is typed) wrapping a single instantiation for the name → className migration with targets = { Member("ExternalCodeReference", "name") }; three activation points (per-site quick-fix on the cursor, file-scoped bulk action, workspace-scoped bulk action emitting a multi-document WorkspaceEdit directly with no executeCommand indirection); diagnostic stance splits on resolution: legacy-and-resolves stays silent (the build-channel LOG.warn in FieldBuilder.parseExternalRef is the migration-tracking signal), legacy-and-unresolved fires error-severity diagnostic mirroring the build’s ExternalRef.lookupError arm and naming the unresolved name plus the two fixes (namedReferences config or write className: directly). Bidirectional drift protection between SDL and the SdlActions registry: every SdlAction.targets() entry must resolve against directives.graphqls’s deprecation markers (SDL `@deprecated() for member-level; structured javadoc-style @deprecated <reason> token in directive description strings for whole-directive); every marker must be covered by either an SdlAction or the MANUAL_MIGRATION_DEPRECATIONS allow-list (at landing: Member("@asConnection", "connectionName") per-instance semantics, WholeDirective("index") deferred to a future per-call-site rewrite). @index’s description string converts the legacy prose "Deprecated: use `@order(index:) instead" to the structured token form so DeprecationMarkers can pick it up; no semantic change for consumers. CompletionData gained a namedReferences slot (4-arg canonical constructor; 3-arg secondary kept for test fixtures); Workspace.openUris() exposed for the workspace-scoped bulk action. Tests: unit-tier DirectiveDefinitionsTest, SdlActionTest (7 cases), DeprecationMarkersTest (10 cases including bundled directives.graphqls parse), SdlActionDriftTest (4 cases including the at-landing-time canonical-set pin); LSP-tier CodeActionsTest (8 cases, including the cycle-2 sibling-diagnostic regression seam asserting the per-site quick-fix surfaces independently of CodeActionContext.diagnostics), DiagnosticsTest extension (9 cases: one resolves-silent fixture, eight per-site unresolved-error fixtures one per ECR-binding directive, plus a canonical @service message-content assertion naming the unresolved name and pointing at the two fixes), ClassNameCompletionsTest extension (existing three-site cases pass after the migration; five new cases cover the previously unwired sites). 171 LSP tests, 0 failures; full graphitron-rewrite install green on Java 25. Carried follow-ups (not in scope, captured for next SdlAction author): CodeActions.countableNoun ignores its parameter and hardcodes the R93 noun (fine for one action; brittle once a second SdlAction lands); applyAll/countResolvable/countSkipped each iterate matches and re-invoke the rewrite, three full passes per file per request (correctness-clean, just wasteful ; a single partition pass producing (edits, skipCount) together would be cleaner). Out of scope: concrete-FQN suggestions for unresolved name: values (deferred until R90 Phase 3’s static-method index lands); renaming the @externalField directive itself (R54, disjoint SDL surface); automating consumer namedReferences config edits.

  • Breaking: missing-vs-null semantics on single-row DML mutations now PATCH-shaped (bulk-dml-mutations, R77 Phase B/C, 4cb22014e + 38e1d5fa3): single-row INSERT/UPDATE/UPSERT no longer write SQL NULL on every classifier-known column regardless of whether the input map carried the key. Insert-side cells now bind DSL.defaultValue(dataType) when the input omits the key (the column default lands; on NOT NULL columns without a default, this surfaces as a NOT-NULL violation rather than the silent null write); DSL.val(value, dataType) when the input carries the key (explicit null writes SQL NULL via typed null binding). Update-side SET clauses are now built from a runtime if (in.containsKey(name)) { sets.put(…​) } walk over tia.setFields(): omitted columns drop out of SET entirely, preserving the existing row’s value (PATCH semantics); explicit-null columns write SQL NULL. The UPSERT update branch shares the same dynamic SET walk over DSL.excluded(col), so an omitted column is no longer overwritten by EXCLUDED.col on conflict (which, paired with the insert-side DEFAULT cell, was silent data loss whenever the column had a default). Migration: callers that relied on the old "always write SQL NULL" behavior for omitted columns must set the field to explicit null in the input map; graphql-java’s argument coercion preserves the absent-vs-null distinction in the resulting Map<String, Object> (Map.containsKey is the dispatch key). The structural pin lives in FetcherPipelineTest.dmlInsertField_*containsKey* / dmlSingleRowUpdateField_emitsDynamicSetWalkOverInKeySet; execution-tier coverage in GraphQLQueryTest.createFilm_omittedFieldUsesColumnDefault, createFilm_explicitNullRaisesError, updateFilm_omittedFieldLeavesColumnAlone_explicitNullWritesNull, upsertFilm_omittedFieldOnInsertBranchUsesColumnDefault, upsertFilm_omittedFieldOnUpdateBranchLeavesColumnAlone. Phase E (44f3a6e0e + afd520e47) extended the same dispatch to the bulk arms across all four verbs and added uniform-shape, no-set-fields-present, and duplicate-lookup-key guards on bulk UPDATE plus the per-arm valueType lift, the centralized empty-list short-circuit, and the inline Postgres-only dialect guard on bulk UPDATE; Phase F (87cfa4814 + 9dd81d093) shipped Sakila execution-tier coverage for the four bulk verbs (DmlBulkMutationsExecutionTest, 18 tests covering bulk INSERT/UPDATE/UPSERT/DELETE projection, missing-vs-null pairs on INSERT/UPDATE/UPSERT, divergent-shape and only-lookup-key rejection paths on bulk UPDATE/UPSERT, duplicate-lookup-key guard on bulk UPDATE, empty-list short-circuit per verb) plus the two single-row only-lookup-key analogues (updateFilm_onlyLookupKeyFields_raisesError, upsertFilm_onlyLookupKeyFields_raisesError in GraphQLQueryTest); Phase G (37622aa75) scrubbed eleven stale mutations.md citations across DmlReturnExpression, FieldBuilder, TypeFetcherGenerator, GraphitronSchemaBuilderTest, and TypeFetcherGeneratorTest. Spec deviation acknowledged at approval: the upsertFilms_doNothingMode_skipsUniformityGuard execution test routed to the pipeline tier (FetcherPipelineTest.dmlUpsertField_bulkInput_doNothingMode_omitsUniformShapeAndSetMapEmits) because PostgreSQL enforces NOT-NULL before evaluating ON CONFLICT, so the doNothing-with-divergent-shapes SQL can’t be exercised against Sakila’s film table; the pipeline-tier substitute pins the structural claim (no firstKeys capture, no setsUpdate walk, no .doUpdate() clause, .onConflict(…).doNothing() chain present, bulk List<Map<?,?>> cast preserved). Closing landing: In Progress → In Review at e9746c7ea; In Review → Done approved on this commit.

  • Path expressions in argMapping (argmapping-path-expressions, R84, b3f85cd91dd082): the right-hand side of an argMapping entry on @service / @tableMethod / @condition may now be a dot-path that walks into nested input fields (e.g. kvotesporsmal: input.kvotesporsmalId). New sealed PathExpr.{Head | Step} carrier replaces Map<String, String> on ArgBindingMap.byJavaName; ArgBindingMap.of(slotTypes, segmentChains) walks segment chains against the GraphQL schema and populates a per-step liftsList flag so the emitter never re-asks the schema. New Result.PathRejected arm covers structural rejections (walk-through scalar/enum/union/interface; unknown segment with closest-match hint). Multi-segment flat paths route through the existing CallSiteExtraction.NestedInputField machinery; intermediate-list paths route through a new ArgCallEmitter.buildListAwarePathExtraction walker that emits element-wise .stream().map(…​).toList() for each liftsList=true segment (one-list-deep and two-list-deep shapes both supported). selection.parseEntries(raw) extracts pure syntax (tokens → segment chains) so R69 can consume it directly. ServiceCatalog.reflectServiceMethod parameter-mismatch hint mentions path expressions on every rejection that prints an argMapping example, and pre-fills a concrete reachable path when exactly one matches the unmatched parameter’s Java type across the field’s slots (7-arg overload threads slot types from ServiceDirectiveResolver). Spec deviations (acknowledged at approval time): Result.PathRejected replaces the spec’s pathError carrier slot since path resolution lives in ArgBindingMap.of and a separate slot would be redundant metadata; the parallel walker in ArgCallEmitter was preferred over augmenting NestedInputField with per-segment liftsList to avoid threading an always-false flag through every R63 site. Deferred: enum/text-map/NodeId leaves combined with intermediate-list paths (no live consumer; emitter rejects the combination with an actionable message). Tests: ArgBindingMapTest (22 cases ; head/step shapes, list-shaped intermediates, scalar walk-through rejection, unknown-segment with candidate hint); ServiceCatalogTest Phase F cases (floor mention, no-args negative, stretch unambiguous-prefilled, ambiguous-fallback, type-mismatch-fallback, 6-arg-overload-fallback); execution-tier GraphQLQueryTest sakila fixtures filmsByPath (one-step), filmsByListPath (one intermediate list), filmsByNestedListPath (two intermediate lists). Build green across all four tiers on Java 25.

  • Sealed resolution outcomes for catalog table/FK lookups (catalog-resolution-sealed-outcomes, R81, c48e532): JooqCatalog.findTable(String) now returns TableResolution.{Resolved | NotInCatalog | Ambiguous(schemas)} and findForeignKeyByName(String) returns ForeignKeyResolution.{Resolved | NotInCatalog}; BuildContext.synthesizeFkJoin returns FkJoinResolution.{Resolved | UnknownTable(name, failure) | UnknownForeignKey(fkName)} so the four Optional<TableRef> and four Optional<FkJoin> rejection sites switch on variant directly instead of fabricating eight distinct "not in catalog" strings. JoinStep.FkJoin.fk is non-null by canonical-constructor enforcement; the redundant String fkName component drops (carriers read fk.sqlName()). Catalog construction asserts every schema in the live jOOQ catalog publishes a generated Tables class, throwing IllegalStateException pointing at <tables>true</tables> on miss; TableEntry.toTableRef is consequently non-Optional. findCandidateSchemasFor deletes (the Ambiguous arm carries the schema list inline); findUnqualifiedTable drops .limit(2) since Ambiguous needs the full list. Diagnostic-builder consolidation: BuildContext.unknownTableRejection switches on TableResolution (Ambiguous → structural with qualified-form suggestions; NotInCatalog → unknownTable with Levenshtein candidates); new sibling unknownForeignKeyRejection covers FK-name misses. ServiceDirectiveResolver.computeExpectedServiceReturnType javadoc drive-by: <jooqPackage><schemaPackage> (post-R78 phrasing). 26 new FkJoin(name, null, …) test fixtures migrate to TestFixtures.foreignKeyRef(…​) factory. Tests: JooqCatalogMultiSchemaTest extends to 45 cases ; three TableResolution arms by name on the multischema fixture (widget resolved, event ambiguous in both schemas, fabricated names NotInCatalog); both ForeignKeyResolution arms; all three FkJoinResolution arms via synthesizeFkJoin; the construction precondition (static verifyTablesClassPresent helper); both diagnostic builders asserting Rejection.AuthorError.UnknownName shape and AttemptKind.FOREIGN_KEY tagging. The FkJoinResolution.UnknownForeignKey arm is structurally unreachable from current production callers (they pre-resolve the FK via findForeignKey before calling synthesizeFkJoin); the taxonomy still expresses completeness so future call sites must handle the shape, and a unit test asserts the variant constructs correctly. Build green: 1350 graphitron unit/pipeline tests + sakila-example end-to-end on Java 25.

  • FK slot pairing reads the FK’s own keyFields list, not the referenced UK’s own field order (R82 follow-up, b3c17e6): BuildContext.synthesizeFkJoin paired slot[i] positionally from ForeignKey.getFields() and ForeignKey.getKey().getFields(). The first is the FK’s referencing-column list in declaration order; the second is the referenced UniqueKey’s own declaration order ; the two are parallel only when the FK’s referenced-column ordering happens to match the parent PK’s declaration order. For an FK declared as e.g. FOREIGN KEY (fk_b, fk_c, fk_a) REFERENCES parent (pk_b, pk_c, pk_a) against a parent with PRIMARY KEY (pk_a, pk_b, pk_c), jOOQ’s getKey().getFields() returns (pk_a, pk_b, pk_c) while getKeyFields() returns (pk_b, pk_c, pk_a). Zipping getFields() against the former produced silent mis-paired slots ; observable as Field<Long>.eq(Field<String>) compile errors in generated @splitQuery rows-method JOIN ON predicates downstream when the FK column types are heterogeneous. The fix swaps f.getKey().getFields() for f.getKeyFields() (the FK’s own ordered referenced-column list, parallel to getFields() by jOOQ’s contract) at BuildContext.java:654. Why R82 missed this: R82 lifted slot orientation (which side is source vs target) into a structural fact, but inherited the intra-FK column pairing from jOOQ’s parallel-list contract without revisiting which list the second side reads from. The structural successor JoinSlotOrientationTest constructs slots directly via TestFixtures.fkJoin rather than driving them through synthesizeFkJoin against a real jOOQ FK, and the retired body-string regression tests (splitTableField_listRowsMethod_reorderedHeteroFk_pairsBySqlNameAndType, childInterfaceField_connection_reorderedCompositeFk_pairsBySqlNameAndType) that would have caught it were dropped in favour of orientation-only structural coverage. sakila’s FKs all declare referenced columns in PK declaration order, so the compile-tier safety net at graphitron-sakila-example was silent too. Reproducer fixture: nodeidfixture.reordered_pk_parent (PRIMARY KEY (pk_a bigint, pk_b varchar, pk_c varchar)) plus nodeidfixture.reordered_fk_child whose CONSTRAINT reordered_fk_child_parent_fkey FOREIGN KEY (fk_b, fk_c, fk_a) REFERENCES reordered_pk_parent (pk_b, pk_c, pk_a) flips the referenced-column order on the FK side. Heterogeneous types make the regression observable as a Java-class divergence in ColumnRef.columnClass ; expected "java.lang.String" but was "java.lang.Long" at slot 0 (source=pk_a, target=fk_b) and the symmetric mismatch at slot 2. Tests: new SynthesizeFkJoinReorderedKeysTest (graphitron/src/test/java/no/sikt/graphitron/rewrite/) drives synthesizeFkJoin against the new FK and asserts (a) jOOQ’s two accessors actually diverge on this FK so the test cannot go silent if a future jOOQ release folds them, (b) per-slot type pairing holds, (c) sourceSideColumns() and targetSideColumns() iterate the FK’s own list. The test fails pre-fix with the slot-0 message above and passes post-fix. Build green: mvn -f graphitron-rewrite/pom.xml install -P!docs -Plocal-db SUCCESS on Java 25; the fix is one line plus a paired comment naming the trap.

  • FK column pairing lifted into typed slots (fk-column-pairing-typed-slots, R82, 2557bf7 + 82d9313): JoinSlot sealed interface (FkSlot pairs source/target columns; LifterSlot collapses both onto a single column by construction so DataLoader-key-tuple-IS-target-column-tuple is a type fact, not a prose precondition). JoinStep.FkJoin and JoinStep.LiftedHop carry List<JoinSlot>; the WithTarget capability returns Iterable<? extends JoinSlot> from slots() so positional access (.get(i), .getFirst(), .subList(…​)) is a compile error at every consumer. BuildContext.synthesizeFkJoin orients each slot at synthesis time so emitter sites read direction-blind (target.<slot.targetSide()>.eq(source.<slot.sourceSide()>)) regardless of which end of the catalog FK each maps to. MultiTablePolymorphicEmitter.matchingParticipantCol retired whole; JoinPathEmitter.emitCorrelationWhere drops its parentHoldsFk parameter and arity-mismatch throw; InlineTableFieldEmitter, InlineLookupTableFieldEmitter, TypeFetcherGenerator retire their parentHoldsFk derivations off cardinality / target-table; SplitRowsMethodEmitter.buildSingleMethod’s if/instanceof FkJoin/LiftedHop block collapses to a single `firstHop.slots() iteration through WithTarget. Producer @LoadBearingClassifierCheck on WithTarget.sourceSideColumns() / key fk-join.slots-oriented-source-and-target paired with @DependsOnClassifierCheck on every migrated reader (FieldBuilder ×3, NodeIdLeafResolver, JoinPathEmitter, TypeFetcherGenerator, MultiTablePolymorphicEmitter ×2). Self-ref deviation from spec: spec promised "no signature change, no caller-supplied hint" on synthesizeFkJoin; the table-name comparison is ambiguous for self-referential FKs (category.parent vs category.children navigate the same FK in opposite directions). The fix threads a selfRefFkOnSource boolean (derived from list-cardinality at the call site) through parsePathparsePathElementsynthesizeFkJoin, consulted only in the self-ref case; non-self-ref FKs ignore it. Tests: JoinSlotOrientationTest (5 model-tier tests pinning slot orientation + the Iterable<? extends JoinSlot> compile-time ban); TestFixtures.fkJoin / liftedHop helpers convert nine test fixtures from positional pairs to slot pairs; the two body-string regressions added at fdfec353 (splitTableField_listRowsMethod_reorderedHeteroFk_pairsBySqlNameAndType, childInterfaceField_connection_reorderedCompositeFk_pairsBySqlNameAndType) retire in favour of the structural model-tier coverage plus the existing compile-tier check at graphitron-sakila-example; two execution-tier tests (inlineTableField_selfRef_listCardinality_returnsChildren, inlineTableField_selfRef_nonRootCategory_hasNoChildren) caught the self-ref ambiguity. rewrite-design-principles.adoc:228 DTO-parent batching recipe updates to the slot-shaped vocabulary. Follow-ups (not blocking): branchParentFkWhere’s class Javadoc still describes the legacy "FK direction is inferred from the FK’s targetTable" framing ; code is now slot-iterating direction-blind; rewrite to match. `batchedBranchJoinPredicate’s `parentPkCols parameter is dead after the lift (the author flagged this in the Javadoc). The self-ref selfRefFkOnSource threading is a design fork worth revisiting: a post-synthesis slot-orientation hint on JoinStep.FkJoin set by the field classifier, or a self-ref classifier check that rejects ambiguous schemas at validate time, would localise the disambiguation closer to where SDL semantics live.

  • Composite-key NodeId condition args land as typed Row<N> end-to-end (query-conditions-composite-key-rown-call-site, R79, 00ca956 + efcf125 + fc075b8 + 0d59ed7 + 7523497 + a428284): the QueryConditions adapter side now hands the composer a typed Row<N><T1, …​, TN> (or List<Row<N><…​>>) instead of erasing to RowN, applying the typed adapter / composer pairs principle (added in aa66c7e). BodyParam.RowEq/RowIn drop the dead javaType slot; TypeConditionsGenerator.rowTypeName builds the parameter type from ColumnRef.columnClass(), replacing DSL.row(new Field<?>[]{…​}) with the typed DSL.row(table.c1, …​, table.cN) form. ArgCallEmitter’s inline arity > 1 path uses a Java-17-compatible raw-`RecordN pattern + cast to the typed Row<N><…​> (parameterized instanceof patterns are JDK 21+); the cast is unchecked at the type-arg level but sound at runtime since the decoder returns Record<N><T1, …​, TN>. Arity > 22 is rejected upstream as a deferred Rejection.structural in NodeIdLeafResolver.resolve with wording tracking the validateChildConnectionParentPk Row22 precedent. The QueryConditionsGenerator shim layer additionally (a) hoists per-class composite-key NodeId decode chains into private static helpers via a CompositeDecodeHelperRegistry deduplicated by (encoderClass, methodName, mode, list) with naming decode<NodeType>{Row,Rows}{,OrThrow}, (b) lifts shared outer-arg Maps to one local when ≥2 NestedInputField callParams reference the same outer arg, and (c) reduces the noCondition()-and chain to a direct return when only one filter applies. Other call sites (Inline*, SplitRows*, TypeFetcher) keep the inline form by passing no registry. Tests: pipeline-tier QueryConditionsPipelineTest (helper-dedup + scalar/list key separation), unit CompositeDecodeHelperRegistryTest (same-key dedup, SKIP↔THROW separation, scalar↔list separation, per-mode body shape ; six tests), unit QueryConditionsGeneratorLiftTest (≥2-share lifts, single does not, distinct outer args do not, cross-filter counts, camelCase naming ; five tests), NodeIdLeafResolverTest.rejects_whenNodeTypeKeyArityExceeds22 against a synthetic 23-column PK in nodeidfixture, and a sakila compilation-tier regression-guard fixture (filmActorsByCompositeNodeIds + FilmActorCompositeNodeIdFilter) exercising the BodyParam.RowIn → typed Row<N> path against real jOOQ. TypeConditionsGeneratorTest.nodeIdInFilter_compositeColumns_emitsRowInWithUntypedRowN renamed and rewritten to assert on the typed form plus a List<Row2<Integer, Integer>> parameter-type sibling assertion.

  • Replace string-scan helper-emission gate with TypeFetcherEmissionContext (type-fetcher-helper-emission-gate, R80, c36734d + c5506e1): TypeFetcherGenerator.generateTypeSpec previously decided whether to emit the graphitronContext helper by serialising every just-emitted method’s CodeBlock and substring-greping for graphitronContext(env) ; an enumerate-or-scan gate whose latest near-miss had silently dropped ServiceRecordField (the only BatchKeyField that doesn’t extend SqlGeneratingField via TableTargetField). Replaced with a per-class TypeFetcherEmissionContext scratchpad: every emitter that writes a graphitronContext(env) call obtains the CodeBlock through ctx.graphitronContextCall(), which records the dependency on the way out; class assembly drains ctx.isRequested(GRAPHITRON_CONTEXT) and emits the helper accordingly. Threaded ctx through ArgCallEmitter (both buildCallArgs overloads, both buildMethodBackedCallArgs overloads, buildArgExtraction), LookupValuesJoinEmitter.buildFetcherBody, SplitRowsMethodEmitter (entry points + emitParentInputAndFkChain), MultiTablePolymorphicEmitter (emitMethods/emitConnectionMethods overloads + the four private build* statics), and the in-file TypeFetcherGenerator.build* privates. Replaced 11 SQL-context literals (graphitronContext(env).getDslContext(env)), the validator pre-step’s getValidator(env), and the multitable tenant-id data-loader name composition with $L interpolation of ctx.graphitronContextCall(). Test impact: graphitronContextHelper_emittedForServiceRecordOnlyClass keeps the helper-presence assertion; the body-string sanity assertion was the test-tier code-string pattern the principles ban, and deletes. Deferred: an architectural review surfaced that the throwaway ctx constructed at three non-Fetchers callers (QueryConditionsGenerator, InlineTableFieldEmitter, InlineLookupTableFieldEmitter) does not record into a context anyone drains, and that @condition(contextArguments: […​]) does reach those callsites (the comment R80 introduced claiming otherwise was wrong). The closing commit c5506e1 corrects the comment; R85 (helper-emission-non-fetcher-hosts) generalises EmissionContext to the Conditions and Type host classes and adds a sakila compile-tier fixture so the path can’t go latent again. Build green: 1308 graphitron unit/pipeline tests on the full mvn -f graphitron-rewrite/pom.xml install -Plocal-db.

  • R68 Phase 1b: tutorial chapter + TutorialSmokeTest (diataxis-user-manual, R68, fa36dbc + d0c63c4): six tutorial pages under docs/manual/tutorial/ plus a @QuarkusTest drift verifier in graphitron-sakila-example. Prose (fa36dbc): 01-prerequisites.adoc (JDK 25, Maven, Docker, git; the docker run -v init.sql:…​ one-liner; mvn -f graphitron-rewrite/pom.xml install -Plocal-db; cd graphitron-rewrite/graphitron-sakila-example && mvn quarkus:dev; introspection-curl smoke check); 02-first-schema.adoc (the three types Query / Customer / Address and how @table + @field map them to PostgreSQL; honest call-out on @nodeId / @node being out of tutorial scope); 03-first-query.adoc ({ customers { firstName lastName email } } against the live server; the projection-narrowing claim with the rendered SQL shape; the customers(active: true) filter and the @field(name: "ACTIVEBOOL") argument-level mapping that wires it); 04-joining-tables.adoc (single-hop @reference(path: [{key: "customer_address_id_fkey"}]) with the rendered LEFT JOIN; multi-hop storeAddress chain through customer.store_id → store.store_id → store.address_id → address.address_id; the inferred-FK shape on Store.customers); 05-mutations.adoc (the FilmCreateInput @table(name: "film") + @mutation(typeName: INSERT) shape; the RETURNING clause keeping the round-trip count to one; UPDATE via @lookupKey on filmId); 06-going-further.adoc (the four post-tutorial recipes: add-custom-conditions, connections + sort-results, error-channel, test-your-schema, plus pointers into the rest of the manual). tutorial/index.adoc replaced with a real path overview (cross-link to the example module on GitHub, time budget, before-you-start orientation pointer to Quick Start). Two prose divergences from the plan’s worked-example sketch: (1) the closing query uses address { address district } instead of address { addressLine1 } because the example schema doesn’t carry addressLine1; (2) the "going further" page links to connections + sort-results rather than the plan’s earlier pagination-and-sorting because Phase 3 split that recipe into the two shipped pages. Smoke test (d0c63c4): TutorialSmokeTest lives next to the existing GraphqlResourceSmokeTest under graphitron-sakila-example, reusing the SmokeTestPostgresResource QuarkusTestResourceLifecycleManager (Testcontainers Postgres or local-db routing via -Dtest.db.url). Six tests one-per-page-or-query: page1_introspectionVerification ({ __typename } returns "Query"), page3_customersBasicSelection (all five customer first names + Mary’s email present), page3_activeFilter (only the three activebool: true rows), page4_singleHopReference (the address { address district } shape with 47 MySakila Drive), page4_multiHopReference (the storeAddress chain returning both store addresses), page5_createAndUpdateFilm (POST createFilm round-trips a filmId > 5; subsequent updateFilm against that id round-trips the renamed title). @AfterEach DELETE FROM film WHERE film_id > 5 keeps ApprovalQueryExampleTest’s five-film pin honest. Plus a "Use GraphiQL instead of curl" subsection on `01-prerequisites.adoc (the bundled playground at http://localhost:8080/graphiql/) and a one-line nudge in 03-first-query.adoc’s lede so a reader who skipped the GraphiQL intro still notices the option. Plan deviation: the plan’s "Tests" section called for a separate `tutorial-smoke-test Maven module wrapping a shell script around mvn quarkus:dev and curl. The shipped shape is a @QuarkusTest inside the existing graphitron-sakila-example test source. The in-module shape exercises the same JAX-RS endpoint and the same GraphqlEngine / GraphqlResource / AppContext bean wiring that mvn quarkus:dev would, runs naturally inside the mvn verify -Plocal-db invocation that CI already runs from the rewrite reactor (no separate module to wire into the reactor or the docs build), and gives a single-class diff site for future tutorial pages. The drift surfaces the plan named (HTTP endpoint shape, directive existence, query shape) are all covered; "mvn flags" drift is covered by the surrounding rewrite-build itself. Plan markers: Phase 1b heading gains a shipped at fa36dbc + d0c63c4 trailer; the deviation is documented inline on the plan page so a future reader doesn’t go hunting for the absent Maven module. Verification: mvn -f graphitron-rewrite/pom.xml -pl graphitron-sakila-example test -Plocal-db 250/250 (244 prior + 6 new); ApprovalQueryExampleTest continues to pin five films (the smoke test cleans up after itself). The drift-protection seam means a directive disappearing, an endpoint moving, or a generated resolver narrowing differently breaks the corresponding tutorial page before the docs ship.

  • R68 Phase 6: cutover quick-start directive pointer to the new manual (diataxis-user-manual, R68, 863d8be): quick-start.adoc:15 flipped from the legacy graphitron-codegen-parent/graphitron-java-codegen/README.md GitHub pointer to the in-tree xref:manual/reference/directives/index.adoc so readers land on the live, drift-protected directive reference Phase 2 shipped under manual/reference/directives/. The other legacy-codegen-parent mentions in /docs/ are correct as-is and did not move on this commit: fallback notes for features the rewrite stubs (@multitableReference, the polymorphic union pattern), the migration recipe itself (how-to/migrating-from-legacy.adoc), and how-to/index.adoc’s note that the legacy README’s worked examples remain a useful cross-reference until R26 retires the legacy modules. The legacy README itself is out of AI edit scope (`CLAUDE.md); the one-paragraph "moved to graphitron.sikt.no/manual/reference/" stub redirect is the Sikt-maintainer companion commit the original Phase 6 body called out, lives outside this plan, and remains the gating step before R26 retires the legacy modules. Plan markers: Phase 6 heading gains a shipped at 863d8be trailer; the body extends with the as-shipped scope (which legacy mentions moved, which stayed) so a future reader can audit the cutover surface without re-grepping /docs/.

  • R68 Phase 4 second half: explanation chapter (diataxis-user-manual, R68, 1ea0855): six new pages under docs/manual/explanation/ plus an updated index mapping them. Architectural framing: why-database-first.adoc (the database is the source of truth; the GraphQL layer is a typed view; cross-links to graphitron-principles.adoc) and why-jooq-and-graphql-java.adoc (jOOQ is where you work, GraphQL-Java is under the hood, the dependency triple constrains and enables; cross-links to dependencies.adoc). Pipeline framing: how-it-works.adoc (the build-time and request-time paths in 30 seconds, named call-outs for parse/classify/validate/emit and graphql-java/DataFetcher/jOOQ/DataLoader at runtime), classifier-mental-model.adoc (the (parent context, return type) two-axis model in user terms; concrete walk-through of a Customer schema showing same-scope join, @splitQuery-driven batch, and @externalField computed field side by side; the unknown-name-with-candidate-hint and directive-conflict rejection shapes), batching-model.adoc (same-scope joins versus cross-scope batches; @splitQuery opens a new scope keyed by parent PK; @lookupKey parameterises a derived target table; the N × M contract that custom @condition methods must respect; the per-request DataLoaderRegistry lifecycle). "Why does it work that way" answers: design-decisions.adoc covers seven user-visible constraints with their rationale (why @condition methods take a table parameter even when not aliased; why @lookupKey blocks pagination; why mutations require @table on the input type rather than reusing the output’s binding; why selection drives projection; why federation _entities is a @lookupKey shape; why the validator’s diagnostics surface is closed-set; why build-time wins over runtime introspection). All pages cross-link into the directive reference, the diagnostics glossary, the how-to recipes, and (where appropriate) the architecture chapter and the existing top-level explanation pages. No verifier: explanation prose is curated voice, not a surface that drifts mechanically against the code; the directive reference (DirectiveDocCoverageTest), Mojo reference (MojoDocCoverageTest), diagnostics glossary (DiagnosticsDocCoverageTest), and deprecations index (DeprecationsDocCoverageTest) carry the bidirectional drift-protection seams. Plan markers: Phase 4 trailer extended to "Shipped at 868593a (runtime-api), d796c4c (mojo + verifier), <TBD> (explanation)"; explanation half no longer outstanding. AsciiDoctor build green: all six pages render without warnings; full site build succeeds.

  • R68 Phase 4 second half: explanation chapter (diataxis-user-manual, R68, 1ea0855): six new pages under docs/manual/explanation/ plus an updated index mapping them. Architectural framing: why-database-first.adoc (the database is the source of truth; the GraphQL layer is a typed view; cross-links to graphitron-principles.adoc) and why-jooq-and-graphql-java.adoc (jOOQ is where you work, GraphQL-Java is under the hood, the dependency triple constrains and enables; cross-links to dependencies.adoc). Pipeline framing: how-it-works.adoc (the build-time and request-time paths in 30 seconds, named call-outs for parse/classify/validate/emit and graphql-java/DataFetcher/jOOQ/DataLoader at runtime), classifier-mental-model.adoc (the (parent context, return type) two-axis model in user terms; concrete walk-through of a Customer schema showing same-scope join, @splitQuery-driven batch, and @externalField computed field side by side; the unknown-name-with-candidate-hint and directive-conflict rejection shapes), batching-model.adoc (same-scope joins versus cross-scope batches; @splitQuery opens a new scope keyed by parent PK; @lookupKey parameterises a derived target table; the N × M contract that custom @condition methods must respect; the per-request DataLoaderRegistry lifecycle). "Why does it work that way" answers: design-decisions.adoc covers seven user-visible constraints with their rationale (why @condition methods take a table parameter even when not aliased; why @lookupKey blocks pagination; why mutations require @table on the input type rather than reusing the output’s binding; why selection drives projection; why federation _entities is a @lookupKey shape; why the validator’s diagnostics surface is closed-set; why build-time wins over runtime introspection). All pages cross-link into the directive reference, the diagnostics glossary, the how-to recipes, and (where appropriate) the architecture chapter and the existing top-level explanation pages. No verifier: explanation prose is curated voice, not a surface that drifts mechanically against the code; the directive reference (DirectiveDocCoverageTest), Mojo reference (MojoDocCoverageTest), diagnostics glossary (DiagnosticsDocCoverageTest), and deprecations index (DeprecationsDocCoverageTest) carry the bidirectional drift-protection seams. Plan markers: Phase 4 trailer extended to "Shipped at 868593a (runtime-api), d796c4c (mojo + verifier), <TBD> (explanation)"; explanation half no longer outstanding. AsciiDoctor build green: all six pages render without warnings; full site build succeeds.

  • R68 Phase 5 closing slice: deprecations index + DeprecationsDocCoverageTest (diataxis-user-manual, R68, 23c2056): aggregator page reference/deprecations.adoc lists every SDL @deprecated() marker in directives.graphqls (currently @asConnection(connectionName:) and ExternalCodeReference.name) plus the one whole-directive deprecation that the GraphQL spec disallows @deprecated on (@index, the legacy alias for @order(index:)). Each row points at the canonical directive reference page for the migration prose (asConnection.adoc, record.adoc, index.adoc); a separate "Rejected, not deprecated" section calls out @notGenerated so authors don’t conflate the categories. Two honest deviations from the plan are documented inline on the page itself: (1) the source of truth is the SDL @deprecated() marker, not Java @Deprecated annotations on a "directive-classification model" (the legacy classification model lives in the out-of-AI-scope legacy modules); (2) the plan’s "target removal version" column is dropped because the rewrite’s @deprecated(reason:) markers do not carry a structured removal version and there is no separate directive-surface versioning cadence to anchor it on. Verifier: DeprecationsDocCoverageTest extracts qualified <parent>.<member> keys from the SDL by walking backwards from each @deprecated hit to the closest directive @<name> or input <Name> declaration, then asserts every key’s two halves both appear in the doc page (rows naturally include both, e.g. @asConnection(connectionName:) and ExternalCodeReference.name). Whole-directive deprecations are covered via a small WHOLE_DIRECTIVE_DEPRECATIONS allow-list (currently Set.of("index")) that a separate test arm asserts against the doc; adding a new whole-directive deprecation requires adding to both the allow-list and the doc, mirroring the bidirectional drift-protection shape of DirectiveDocCoverageTest. The reference index gains a "Deprecations" section pointing at the new page. Plan markers updated: Phase 5 marked shipped on the heading; Phase 4 gains a "shipped at 868593a (runtime-api) and d796c4c (mojo + verifier)" trailer with explanation pages explicitly noted as outstanding (the second-half Phase 4 slice). Build green: 258 sakila-example tests pass on Java 25.

  • Multi-schema @table rejection: structured ambiguity message + R68 docs delta (R78 follow-up, 41636e9 + 36238b0): closes the "Open follow-ups" line on R78’s changelog entry by landing both the documentation delta R78 deferred and the user-facing rejection R78’s spec quoted but R78 didn’t actually wire. New BuildContext.unknownTableRejection(String sqlName) helper consolidates the @table(name:) rejection-construction decision: branches on JooqCatalog.findCandidateSchemasFor (size >= 2 emits a structural ambiguity message naming the colliding schemas plus inline qualified-form suggestions; otherwise falls through to the existing Rejection.unknownTable with the Levenshtein-ranked candidate hint, covering missing names, qualified misses, and the degenerate single-schema-with-no-Tables-class case). The three @table-directive sites in TypeBuilder (buildTableType, buildTableInterfaceType, buildTableInputType) all route through it, so the better message reaches authors at every directive site that resolves a name: argument. The eight non-@table-directive resolveTable callsites (one in NodeIdLeafResolver, four in BuildContext resolving table names from FK metadata or other classifier-internal sources) keep the existing rejection shape since @table(name:) framing doesn’t fit those error contexts. Docs: reference/directives/table.adoc grows a sentence under name: describing the qualified form and a constraint bullet quoting the new rejection verbatim; how-to/map-types-to-tables.adoc grows a "Tables in non-default schemas" subsection (three SDL snippets covering unique-across-schemas + two qualified-form schemas plus a paragraph on the failure modes); the diataxis-user-manual plan’s "Pending content additions" section flips from "Backlog candidate" to "done" inline. Tests: three new cases in JooqCatalogMultiSchemaTest pin the three branches against the multischema_a/multischema_b fixture (ambiguous unqualified, missing unqualified, qualified miss); construction passes null schema and null ctx since the helper only touches the catalog and BuildContext.buildTypeNamesByTableKey already null-guards. 1307 graphitron + 256 sakila-example + 48 graphitron-maven tests green.

  • Typed jOOQ class references for multi-schema correctness (jooq-multi-schema-typed-references, R78, b334036 + 6add327 + 9e417b5 + 4a61223 + ab64ad0 + 64405d6): fixes a latent multi-schema generated-code bug (imports emitted as <jooqPackage>.tables.X, dropping the schema segment) and a parallel silent-wrong-schema resolution bug (JooqCatalog.findTable picked whichever schema iterated first on a name collision) by replacing string concatenation against String jooqPackage with javapoet ClassName values populated once at parse time from Table<?> reflection. Model: TableRef carries tableClass (the <schemaPackage>.tables.<X> class), recordClass (the <X>Record class via Table.getRecordType()), and constantsClass (the schema’s Tables class) as ClassName; the prior String javaClassName field is gone. New ForeignKeyRef(sqlName, keysClass, constantName) replaces String fkJavaConstant on JoinStep.FkJoin; the Keys host class is the FK-holder schema’s so cross-schema FKs join correctly without per-emitter schema arithmetic. Catalog API: JooqCatalog.TableEntry exposes tableClass(), recordClass(), constantsClass() typed accessors plus toTableRef(sqlName) as the single factory, collapsing BuildContext.resolveTable and ServiceCatalog.buildTableRef (which already returned Optional<TableRef>); findForeignKeyByName(sqlConstraintName): Optional<ForeignKeyRef> replaces fkJavaConstantName(): Optional<String>. Catalog-miss is structural: BuildContext.resolveTable returns Optional<TableRef> and synthesizeFkJoin returns Optional<FkJoin>; consumers route absence through UnclassifiedType / UnclassifiedField rather than empty-string sentinels, so emit sites never see a partial ref. Resolution-time disambiguation: new parseQualifiedTableName(String) plus two findTable shapes (findTable(qualifiedName) and findTable(schemaSqlName, tableSqlName)); unqualified @table(name: "x") resolves iff exactly one schema contains x, collisions reject with "@table(name: 'film') is ambiguous: defined in schemas [public, archive]; qualify as 'public.film' or 'archive.film'", qualified @table(name: "schema.x") scopes to the named schema. The eight directive-parsing sites (three in BuildContext, four in TypeBuilder, one in NodeIdLeafResolver) all participate; directive SDL signature is unchanged. jooqPackage survivors: only JooqCatalog’s constructor (loads `<jooqPackage>.DefaultCatalog reflectively) and CatalogBuilder’s filesystem-path conversion (`replace('.', '/')); no emitter takes a jooqPackage parameter, no concrete + ".tables" / + ".tables.records" / ClassName.get(jooqPackage, "Tables" | "Keys") concatenation survives anywhere in graphitron/src/main/. NodeIdEncoderClassGenerator reads nt.table().constantsClass() per NodeType instead of synthesising one from jooqPackage; QueryNodeFetcherClassGenerator.generate drops a dead jooqPackage parameter; EntityFetcherDispatchClassGenerator and SelectMethodBody switch to entity.table().tableClass(); GeneratorUtils.ResolvedTableNames collapses to a single typeClass field with the other two reading from TableRef; the private buildRowKey, buildAccessorKey{Single,Many}, buildKeyExtraction{,WithNullCheck} helpers become parameter-pure on jooqPackage. Test surface: new multischema_a + multischema_b jOOQ-codegen fixture in graphitron-sakila-db with cross-schema FK (gadget → widget), a shared event table in both schemas, and a unique widget / gadget per schema; JooqCatalogMultiSchemaTest asserts cross-schema tableClass / recordClass FQNs (multischema_a.tables.Widget, multischema_a.tables.records.WidgetRecord), the FK-holder Keys class for cross-schema traversal (multischema_b.Keys), schema-qualified resolution, unqualified-and-unique resolution, and the empty-on-ambiguity policy for the shared event name; TestFixtures.tableRef(…​) helper centralises the new ClassName construction across the 107 test sites. Phasing: Phase 1 fixture landed first to turn the compilation tier red; Phase 2 (catalog API + TableRef migration + qualified-name resolver) was the load-bearing change; Phase 3 (ForeignKeyRef + record-class typed refs) and the hotfix sweep finished emit-side cleanup; Phase 4 dropped the dead jooqPackage parameter threading uncovered by the post-migration audit, closing the boundary on both sides (no concatenation in bodies, no parameter on signatures). Open follow-ups: docs delta for the @table(name: "schema.table") syntax (folded into R68’s ## Pending content additions for next-touch-of table.adoc and map-types-to-tables.adoc); execution-tier coverage for cross-schema FK joins at runtime (Backlog stub); LSP directive-validation pass for the dotted syntax (one-line check, not a hard R78 requirement). Build green: mvn -f graphitron-rewrite/pom.xml install -Plocal-db SUCCESS on Java 25.

  • TableRecord-keyed Map returns on @service rows methods (service-rows-tablerecord-key-shape, R70): closes R32’s deferred "element-shape conversion when the developer’s Sources is Set<TableRecord> / List<TableRecord>`" bullet by extending the variant taxonomy rather than threading conversion through the emitter. Two new `BatchKey.ParentKeyed permits ; TableRecordKeyed(parentKeyColumns, elementClass) and MappedTableRecordKeyed(parentKeyColumns, elementClass) ; carry the developer-declared Class<? extends TableRecord<?>> on the variant; keyElementType() returns it directly. ServiceCatalog.classifySourcesType’s `TableRecord element branch reroutes onto the new permits, threading the typed class. Three downstream sites widen their isMapped instanceof checks to include MappedTableRecordKeyed (RowsMethodShape.outerRowsReturnType, TypeFetcherGenerator.buildServiceDataFetcher, TypeFetcherGenerator.buildServiceRowsMethod). GeneratorUtils.buildKeyExtraction’s sealed switch grows one arm emitting `Record) env.getSource(.into(Tables.X) with Tables.X resolved from the parent table; the rows-method emitter’s return ServiceClass.method(keys) line covers the new variants by construction (the lambda’s keys local is already typed Set<X> / List<X>, the developer’s signature matches, the call type-checks). The deferred-conversion comment on buildServiceRowsMethod drops out. Resolver-side parent-table consistency check: ServiceDirectiveResolver.validateTableRecordSourceParentTable rejects Set<X> against a parent whose record class isn’t X with a candidate-hint pointer; without it the typed into(Tables.X) projection on a wrong-typed parent would silently produce nonsense. New helper BuildContext.recordClassForTypeName(parentTypeName) reads the @table directive on the parent type and looks up the catalog. MappedRowKeyed / RowKeyed docstrings tighten to "only Set<RowN<…​>> / List<RowN<…​>> classify here"; the variants are now shape-pure, matching RecordKeyed / MappedRecordKeyed siblings. Tests: BatchKeyTest extends the per-variant shape map with the two new permits (keyElementType() returns FilmRecord, javaTypeName() yields java.util.{List,Set}<…​FilmRecord>); ServiceCatalogTest’s two existing `TableRecord cells flip from RowKeyed / MappedRowKeyed to TableRecordKeyed / MappedTableRecordKeyed. L5 + L6: new Film.titleTitlecase schema field paired with FilmService.titleTitlecase(Set<FilmRecord>) → Map<FilmRecord, String> exercises the typed-record path end-to-end against the sakila PostgreSQL fixture; GraphQLQueryTest.films_titleTitlecase_resolvesViaServiceRecordFieldDataLoader_tableRecordSource runs { films { title titleTitlecase } } and asserts each title round-trips through the typed-record extraction. Builds on R61 (variant identity tracks shape; Record.into(Table) projection at the parent-key extraction site, structurally identical to RecordKeyed / MappedRecordKeyed arms but typed to the developer’s element class). Open follow-ups (deferred): single-cardinality typed-record positional signature (X method(X parent) driven by LoaderDispatch.LOAD_ONE) ; confirm if the same TableRecordKeyed permit covers it cleanly when a real schema needs it; custom-scalar V-types in the typed-record map inherit RowsMethodShape.strictPerKeyType’s null-skip arm until R45 lands. Post-landing addition (`c6a10133): composite-PK regression-guard cell ServiceCatalogTest.reflectServiceMethod_compositeKeyTableRecordSources_classifiedAsMappedTableRecordKeyed pinning Set<FilmActorRecord> (2-PK) onto MappedTableRecordKeyed so a future classifier collapse onto MappedRowKeyed is caught at L1 rather than at consumer-build time; mirrors the consumer’s Set<KvotesporsmalRecord> 3-PK shape. Editorial follow-ups noted at approval (not blocking): approval pass on 2026-05-08 (4 days after In Review) confirmed all R70 invariants survive substantial post-landing drift through R102 (BatchKey invariants + record components + validator generalisation), R77 (bulk DML emit), R82 (slot lift), R78 (jOOQ multi-schema typed references ; parentTable extraction now resolves through TableRef.constantsClass()), R104 (TypeRegistry / FieldRegistry), and R114 (multi-hop @reference); the Record) env.getSource(.into(Tables.X) extraction continues to compile and run the L6 sakila path. Duplicate-key DataLoader behaviour with TableRecord keys is structurally sound (the mapped DataLoader factory routes through the same hashing path that already works for RecordN keys, and org.jooq.impl.AbstractRecord.equals/hashCode is value-array-based) but the L6 fixture exercises 5 unique films so the de-duplication path is not directly asserted; worth a follow-up sibling if a regression ever surfaces. Build green: mvn install -Plocal-db SUCCESS on Java 25; 1268 unit + pipeline tests pass; 186 GraphQLQueryTest execution tests pass.

  • selection/ parser audit (selection-parser-audit, R30): audit found the parser IS needed. @experimental_constructType(selection: "…​") carries a generation-time string argument; graphql-java’s DataFetchingFieldSelectionSet / SelectedField APIs only exist inside a live query execution and cannot substitute. The selection/ package stays; wiring it into the @experimental_constructType classifier is tracked separately.

  • Promote graphitron-test to graphitron-sakila-example (rename, Quarkus runtime, consumer test pattern) (rewrite-example-quarkus-jaxrs, R67, 4c2dc5d + b869b6e + e5314e9 + 4af7001): turns the rewrite’s internal end-to-end test module into a public-facing artifact that doubles as the runnable reference application and the recommended consumer test pattern, closing both the docs gap (docs/quick-start.adoc:21,64 no longer points at the legacy graphitron-example on the retired graphitron-servlet runtime) and the absence of a documented "how do I test my schema" answer for rewrite consumers. Stage 0 (4c2dc5d) splits graphitron-fixtures into graphitron-sakila-db (catalog + jOOQ codegen) and graphitron-sakila-service (Java service fixtures); renames graphitron-test to graphitron-sakila-example; relocates the tier-annotation package (@UnitTier / @PipelineTier / @CompilationTier / @ExecutionTier under no.sikt.graphitron.rewrite.test.tier) from graphitron-fixtures’s main source root into `graphitron’s test source root, republished as a `tests test-jar via maven-jar-plugin’s `test-jar goal so import paths stay stable across the rename; updates every dependent (graphitron, graphitron-lsp, the two graphitron-maven ITs, CLAUDE.md, .claude/web-environment.md, graphitron-rewrite/docs/{README,testing,rewrite-design-principles}.adoc, six javadoc/code comments, SampleQueryService and MutationPayloadLifterTest javadoc). Stage 1 (b869b6e) layers Quarkus 3.34.5 + JAX-RS onto graphitron-sakila-example: imports quarkus-bom, drops the test-scope hibernate-validator + expressly pair for compile-scope quarkus-hibernate-validator, adds quarkus-rest, quarkus-rest-jackson, quarkus-config-yaml, quarkus-jdbc-postgresql, quarkus-agroal, the quarkus-junit5 + rest-assured test pair, and the quarkus-maven-plugin execution. Hand-written runtime under app/: GraphqlEngine (@ApplicationScoped, builds the schema once via Graphitron.buildSchema(b → {})), GraphqlResource (@Path("/graphql"), POST application/jsonapplication/graphql-response+json per the GraphQL-over-HTTP spec, GET for query-only, fresh DataLoaderRegistry and AppContext per request stashed under GraphitronContext.class on ExecutionInput), AppContext (implements GraphitronContext, per-request DSLContext from the Quarkus-managed AgroalDataSource plus a context-values map fed into getContextArgument). application.yaml configures HTTP port and JDBC datasource via ${VAR:default} env-var defaults pointing at the local-db Postgres. One smoke test (GraphqlResourceSmokeTest + SmokeTestPostgresResource QuarkusTestResourceLifecycleManager) POSTs { customers { firstName } } and asserts 200 + non-empty; in-process query-to-database tests run unchanged alongside it. default-compile pinned to <release>17</release> to keep the consumer-runtime app code under the rewrite’s Java-17 generated-output guarantee. Stage 2 (e5314e9) curates the test surface: 11 existing tests split into src/test/java/…​/querydb/ (the four query-to-database tests GraphQLQueryTest, FederationEntitiesDispatchTest, FederationBuildSmokeTest, NoFederationRegressionTest) and internal/ (the seven generator-internal tests). IdempotentWriterTest relocates from graphitron/src/test/java/no/sikt/graphitron/rewrite/ into graphitron-sakila-example/…​/internal/ with explicit imports for RewriteContext and GraphQLRewriteGenerator since the package-relative resolution no longer works after the move. Two new worked examples land under querydb/: MatchQueryExampleTest (load .graphql, execute, assert specific paths) + customers_basic.graphql, and ApprovalQueryExampleTest + films_basic.{graphql,approved.json} (execute, serialise to canonical JSON, compare; on divergence write a sibling .actual.json so the next iteration is "diff the two; mv onto approved if intentional"). README.md lands at the module root: opens with the two roles (runnable reference, recommended test pattern), tables which directories to copy for each role, walks through the runtime files, names the two test patterns plus the carve-out for internal/ ("you do not need to copy anything from internal/`"). Stage 3 (`4af7001) repoints docs/quick-start.adoc:21,64 at graphitron-sakila-example; the "Working example" section grows a one-paragraph mention that the same module doubles as the recommended consumer test pattern, with a link to its README; graphitron-rewrite/docs/getting-started.adoc "Hello world" gains a one-line pointer at the example module after the per-request-context worked example. Verification: mvn -f graphitron-rewrite/pom.xml install -Plocal-db builds clean on Java 25 with the example module’s main jar compiled under Java 17; all 1643 tests pass (244 in graphitron-sakila-example); mvn quarkus:dev from the example module boots and serves real Sakila customer rows over HTTP. Unblocks R26 (retire-maven-plugin) on the docs-pointing side and lays the public-facing artifact R68 (diataxis-user-manual) anchors its tutorial chapter and test-your-schema.adoc how-to on. Out of scope and explicitly deferred: deleting legacy graphitron-example/ (R26 owns that gating); HTTP-shaped query-to-database tests (the in-process pattern via graphql-java stays canonical; the smoke test is the only HTTP-shaped check in the module); pedagogical schema simplification (getting-started.adoc remains the on-ramp); test-pattern variants beyond approval + match (richer taxonomies are future follow-ups).

  • Make the typed Rejection hierarchy load-bearing across producers (lift-unclassified-field-onto-sealed-result, R58, 7c10226 + 09541ed + 5d29a3d + 68a062c + 83816e0 + 3dcd3c6): replaces the flat (RejectionKind kind, String reason) pair on UnclassifiedField / UnclassifiedType / ValidationError with the sealed Rejection hierarchy and threads the typed shape from every producer site through every consumer. Top-level AuthorError | InvalidSchema | Deferred, sub-sealed AuthorError.{UnknownName | Structural} and InvalidSchema.{DirectiveConflict | Structural}, StubKey.{VariantClass(@Nullable Class<? extends GraphitronField>) | EmitBlock(EmitBlockReason)}, with a self-contained candidateHint renderer so the model package can render rejection messages without pulling in BuildContext. RejectionKind survives as a derived projection (RejectionKind.of(Rejection)) for the [<kind>] <message> log surface. Phase 0 (7c10226) drops RejectionKind.INTERNAL_INVARIANT; the single producer at FieldBuilder.classifyChildFieldOnTableType’s nested-fields fallthrough becomes an `AssertionError. Phase A (09541ed) introduces the seal and lifts UnclassifiedField to carry Rejection rejection; every classifier Resolved.Rejected arm widens. Phase B (5d29a3d) mirrors the lift onto UnclassifiedType ; 24 sites (21 in TypeBuilder, 3 in EntityResolutionBuilder); three table-resolution sites construct AuthorError.UnknownName via Rejection.unknownTable. Phase C (68a062c) renames TypeFetcherGenerator.NOT_IMPLEMENTED_REASONS to STUBBED_VARIANTS (Map<Class, Rejection.Deferred>); the four SplitRowsMethodEmitter.unsupportedReason overloads collapse onto Optional<Rejection.Deferred> keyed by EmitBlockReason. Phase D (83816e0) walks the direct candidate-hint producers onto typed AuthorError.UnknownName factories (BatchKeyLifterDirectiveResolver, ServiceCatalog via widening ServiceReflectionResult.failureReason: String → rejection: Rejection, FieldBuilder for @nodeId(typeName:) / column-on-FK-resolved-table / scalar-column-miss / DmlKindResult.Unknown); adds factories unknownTypeName, unknownEnumConstant, unknownNodeIdKeyColumn, unknownDmlKind and the leaf-arm prefixedWith(String) instance method (used by the four wrapper sites that thread caller-specific prose onto ServiceReflectionResult.rejection); drops unused AttemptKind.{TABLE_METHOD, ARGUMENT_NAME, FIELD_NAME}. Phases E–I (3dcd3c6): E replaces the nested-rewrap switch in FieldBuilder.classifyChildFieldOnTableType with a single unc.rejection().prefixedWith(parentPrefix) call so the inner variant’s typed components survive the rewrap (an LSP fix-it on a nested column miss no longer has to re-derive candidates by re-running the classifier). F lifts detectChildFieldConflict, detectQueryFieldConflict, and detectTypeDirectiveConflict from String to Rejection.InvalidSchema.DirectiveConflict; migrates explicit conflict sites (@service`@mutation`, `@notGenerated`, `@asConnection`@splitQuery, @asConnection`@lookupKey` at `LookupKeyDirectiveResolver`); `InvalidSchema.Structural` retains 5 classifier-side producers (root invariants, Connection-at-root for `@tableMethod`, single-cardinality `@lookupKey`, circular type, `@error` field shape) so the seal stays valid. *G* introduces the `ConditionJoinReportable` capability (unsealed, mirrors `BatchKeyField`); the four `ChildField` variants that share the condition-join predicate (`SplitTableField`, `SplitLookupTableField`, `RecordTableField`, `RecordLookupTableField`) implement it with their per-variant `EmitBlockReason` and `displayLabel`; the four `unsupportedReason` overloads collapse to one capability dispatch and the validator's 4-arm `instanceof` chain collapses to a single `instanceof ConditionJoinReportable` check. *H* collapses `StubKey.None` onto a nullable `VariantClass.fieldClass` (post-Phase-D the inline-`Deferred` producer set is exactly 3 sites without natural variant-class anchors); the four `Rejection.deferred(...)` factories collapse to two: `deferred(summary, planSlug, fieldClass)` and `deferred(summary, planSlug)`. *I* lifts `ValidationError` from `(RejectionKind kind, String coordinate, String message, SourceLocation location)` to `(String coordinate, Rejection rejection, SourceLocation location)` with `kind()` and `message()` projecting from the rejection; all 33 sites in `GraphitronSchemaValidator`, the 2 sites in `GraphitronSchemaBuilder.buildRecipeErrors`, and the watch-mode test fixture migrated; `validateUnclassifiedField` / `validateUnclassifiedType` / `emitDeferredError` use `prefixedWith` to preserve the typed variant under the validator's per-site prose prefix. *Tests*: `R58TypedRejectionPipelineTest` (8 cases) covers the migrated producers end-to-end ; `unknownColumn` (direct + nested-rewrap survival), `unknownTypeName`, the `unknownServiceMethod` four-wrapper prefix path, the directive-conflict cases (`@service`@mutation and @table+@record), the ConditionJoinReportable capability seal, and the validator-side UnknownName survival through prefixedWith onto ValidationError; RejectionRenderingTest extended with 8 model-tier cases for the new factories and prefixedWith preservation across every sealed leaf. Out of scope and tracked separately: deeper carrier widenings whose producers Phase D could not migrate without changing intermediate carriers (ParsedPath.errorMessage, InputFieldResolution.Unresolved.reason, ArgumentRef.ScalarArg.UnboundArg.reason, EnumMappingResolver.EnumValidation.Mismatch joined-prose aggregation, TypeBuilder.keyColumnErrors / failures aggregation) tracked under R66 (rejection-string-carrier-widening); LSP fix-its consuming AuthorError.UnknownName.candidates are R18; threading nested rejection chains as a typed Rejection.NestedReject arm deferred until error-aggregation consumers (LSP, watch-mode) demand it; ArgumentRef.UnclassifiedArg.reason and BuildWarning.message lifts (separate axes, single producers); RejectionKind rename. Build green: mvn -f graphitron-rewrite/pom.xml install -Plocal-db SUCCESS on Java 25.

  • Tighten accessor-derived BatchKey model and emitter coordination (accessor-batchkey-emitter-tightening, R65, b0c6846): six independent architectural cleanups surfaced during the R60 reviewer pass, all landed as one commit. (1) Drop AccessorRowKeyedMany.Container: enum + record component gone; GeneratorUtils.buildAccessorRowKeyMany’s for-loop iterates any `Iterable so the LIST/SET split was never load-bearing. The Set<X> vs List<X> parent-class declaration is still exercised by the two pipeline-tier fixtures (ListPayload, SetPayload); the variant just stops preserving which side it came from. FieldBuilder.AccessorMatch.Many no longer carries the container; BatchKeyTest and the ACCESSOR_ROWKEYED_MANY_*_ACCESSOR pipeline cases dropped the enum-pinning assertions. (2) BatchKeyField#emitsSingleRecordPerKey() capability: new default method on BatchKeyField returning false; overridden on SplitTableField (!returnType().wrapper().isList()) and RecordTableField (batchKey() instanceof AccessorRowKeyedMany). The two consumer sites ; TypeFetcherGenerator’s `scatterSingleByIdx helper-emission gate and SplitRowsMethodEmitter.buildForRecordTable’s `buildSingleMethod routing ; both fold onto the capability, so a future variant whose rows-method emits 1 record per key implements the capability without adding a third disjunct at either site. (3) RecordParentBatchKey#preludeKeyColumns() capability + prelude param tightening: new abstract method; RowKeyed delegates to parentKeyColumns(), the three target-side arms (LifterRowKeyed, AccessorRowKeyedSingle, AccessorRowKeyedMany) delegate to targetKeyColumns() via hop.targetColumns(). SplitRowsMethodEmitter.emitParentInputAndFkChain’s prelude param tightened from `BatchKey to RecordParentBatchKey; the pkCols switch with default → throw collapsed to batchKey.preludeKeyColumns(). Helper-method chain (buildListMethod / buildSingleMethod / buildConnectionMethod) tightened to match. To carry the chain end-to-end, SplitTableField.batchKey() and SplitLookupTableField.batchKey() tightened from BatchKey.ParentKeyed to BatchKey.RowKeyed (which already implements both ParentKeyed and RecordParentBatchKey); deriveSplitQueryBatchKey return type matches. The two @DependsOnClassifierCheck annotations on the prelude collapsed into one (the JOIN-on side claim about LiftedHop); the BatchKey-side claim is now load-bearing in the type system. TypeClassGenerator.collectBatchKeyColumns’s redundant `instanceof BatchKey.RowKeyed checks became direct accessor reads on stf.batchKey().parentKeyColumns(). (4) Container/element classifier walk lifted into ServiceCatalog: new ServiceCatalog.ContainerKind { SINGLE, LIST, SET } enum + ContainerSplit record + peelContainer(Type, Set<ContainerKind>) helper. classifySourcesType (SOURCES path) accepts LIST | SET; FieldBuilder.classifyAccessorReturn (accessor path) accepts all three. Element-class checking (jOOQ TableRecord subtype, or RowN/RecordN parameterised raw on the SOURCES path) stays per-caller. Both call sites remain inside parse-boundary classes; the shape walk has one home. (5) Typed LoaderDispatch projection: new BatchKey.LoaderDispatch { LOAD_ONE, LOAD_MANY } enum and RecordParentBatchKey#dispatch() accessor; the three single-key arms return LOAD_ONE, AccessorRowKeyedMany returns LOAD_MANY. TypeFetcherGenerator.buildRecordBasedDataFetcher reads batchKey.dispatch() once and forks the loader value type and dispatch call shape on the projection (replacing the inline instanceof AccessorRowKeyedMany). The @DependsOnClassifierCheck annotation on buildRecordBasedDataFetcher was rewritten to reference the dispatch == LOAD_MANY rule; the producer-side description on accessor-rowkey-cardinality-matches-field was tightened in the same pass to drop the obsolete usesLoadMany term. (6) Delete unused ListAccessorOnSingleField fixture: the record had javadoc noting it existed "for symmetry"; no test referenced it. The unused FilmActorRecord import dropped with it. Deviations from spec: (a) Item 5 implemented as enum rather than the spec’s proposed sealed LoaderDispatch { LoadOne | LoadMany } since both arms carry no per-arm data and consumers fork on identity, not on captured fields (per the design principle "When variants carry different data, use a sealed interface; an enum forces every variant to have the same shape" ; both arms share the empty shape). (b) Item 3’s SplitTableField / SplitLookupTableField batchKey() tightening was not explicitly called out by the spec, but proved necessary to type the prelude parameter as RecordParentBatchKey end-to-end (the alternative was a runtime cast at the call site); type-only narrowing, no behavioural change. (c) Items 2 and 5 capabilities kept separate rather than collapsed: emitsSingleRecordPerKey is a BatchKeyField-level question (rows-method shape; depends on field cardinality); dispatch is a RecordParentBatchKey-level question (loader call shape). They coincide for RecordTableField with AccessorRowKeyedMany (both true / LOAD_MANY) but diverge for single-cardinality SplitTableField (emitsSingleRecordPerKey == true, no dispatch projection ; SplitTableField carries RowKeyed whose dispatch() is LOAD_ONE regardless of field cardinality). Verification: mvn -f graphitron-rewrite/pom.xml install -Plocal-db SUCCESS on Java 25; 1262 unit + pipeline tests pass; 238 graphitron-test compilation + execution tier tests pass; LoadBearingGuaranteeAuditTest no orphans. Out of scope (unchanged): renaming AccessorRowKeyedSingle / AccessorRowKeyedMany (names accurately reflect cardinality at the variant level); the Single permit’s emitter wiring (already complete in R60; execution-tier coverage gap tracked under the validator’s Invariant #10 lift); RecordBatchKeyResolution and the AccessorDerivation / AccessorMatch two-stage builder hierarchy (clean applications of "Builder-step results are sealed").

  • @service rows-method body ; strict return-type validation + shape lift to model (service-rows-method-body, R32, 64b8e2c + e28540b + 83bcfdf): closes out R32 by mirroring ServiceCatalog.reflectServiceMethod’s strict-return check on the child `@service path and resolving the (returnType, BatchKey) → Map<K, V> / List<List<V>> / List<V> cross-product once at the model layer. Iteration 1 (the body emission, separately captured below as befc156) shipped earlier; this entry covers iterations 2 and 3 plus a review-pass nit. Iteration 2 ; strict child-@service validation: ServiceDirectiveResolver.validateChildServiceReturnType rejects developer methods whose declared return type doesn’t structurally match the rows-method’s outer shape. Per-key V derives from ReturnTypeRef ; raw org.jooq.Record for TableBoundReturnType, the backing class for ResultReturnType with non-null fqClassName, the standard Java type for the five standard GraphQL scalars (String / Boolean / Int / Float / ID); other cases (custom scalars, enums, PolymorphicReturnType, ResultReturnType with no backing class) skip the strict check. Carries the service-directive-resolver-strict-child-service-return @LoadBearingClassifierCheck key, paired with @DependsOnClassifierCheck on TypeFetcherGenerator.buildServiceRowsMethod; the emitter can now emit return ServiceClass.method(<args>); against a structurally-typed return without a defensive cast or wildcard local. Author errors surface at classify time rather than as javac errors on the generated source. Iteration 3 ; lift rows-method shape onto the model: validator and emitter each used to reconstruct Map<K, V> / List<List<V>> / List<V> from (returnType, batchKey) independently, the per-key V derivation lived a third time on ChildField.ServiceRecordField.elementType() with a deliberately-divergent fallback, and GeneratorUtils.keyElementType had been bumped to public so the classifier-tier validator could import from the generators package. The shared form lives in two new model-package surfaces: BatchKey.keyElementType() (a default accessor on the sealed root, replacing the static helper in GeneratorUtils) and RowsMethodShape.{strictPerKeyType, outerRowsReturnType, standardScalarJavaType} (the per-key V decision and the (isMapped, isList) outer-shape construction). Validator and emitter both call RowsMethodShape.outerRowsReturnType(perKey, returnType, batchKey); only the perKey input differs (validator: RowsMethodShape.strictPerKeyType and skip on null; emitter: the field-known V from the literal RECORD constant or srf.elementType()). The @LoadBearingClassifierCheck / @DependsOnClassifierCheck pair still holds the contract at audit time, but the construction can no longer drift across sites. GeneratorUtils.keyElementType is gone; the class reverts to package-private and the classifier-tier validator no longer imports from the generators package. Review-pass nit (83bcfdf): split the cast from the value extraction in validateChildServiceReturnType so the Param.Sourced filter uses class::isInstance / class::cast and assigns to a typed local before reading .batchKey(). Behaviour-preserving; null-tolerance contract unchanged. Tests: GraphitronSchemaBuilderTest.UnclassifiedFieldCase.CHILD_SERVICE_TABLE_BOUND_WRONG_RETURN_REJECTED (declared LanguageRecord instead of List<Record>) and CHILD_SERVICE_SCALAR_WRONG_VALUE_TYPE_REJECTED (declared Map<Record1<Integer>, Integer> for a String-valued field) pin the two rejection arms; the previously-shipped GraphQLQueryTest.films_titleUppercase_resolvesViaServiceRecordFieldDataLoader continues to exercise the end-to-end positive path against PostgreSQL. The dropped positive cell collapses onto the validator-and-emitter shared RowsMethodShape.outerRowsReturnType call so structural drift between them is no longer reachable. Open follow-ups (deferred or tracked elsewhere): element-shape conversion when the developer’s Sources is Set<TableRecord> / List<TableRecord> (deferred until a real schema needs it; builds on top of R61); the Row1Record1 framework switch (R61, emit-record1-keys-instead-of-row1.md); ParamSource.Context’s typed registry (tracked under `typed-context-value-registry.md).

  • Auto-derive BatchKey from typed TableRecord accessor on @record parents (auto-derive-batchkey-from-typed-record-accessor, R60, 14889c1 + aabd7ea + b2ae55d): closes the @record-parent free-form-DTO rejection in FieldBuilder.classifyChildFieldOnResultType for the case where the parent class already exposes a typed zero-arg instance accessor returning a concrete jOOQ TableRecord (single, List<X>, or Set<X>). The classifier reflects on the parent class once at build time, matches accessors by name (literal, get<Ucfirst>, is<Ucfirst>) and shape (X, List<X>, Set<X> for X extends TableRecord whose mapped table equals the field’s @table return), and produces one of two new BatchKey.RecordParentBatchKey permits ; AccessorRowKeyedSingle(JoinStep.LiftedHop, AccessorRef) for single-cardinality fields, AccessorRowKeyedMany(JoinStep.LiftedHop, AccessorRef, Container) for list / set fields ; without requiring the schema author to add @batchKeyLifter. The three-option AUTHOR_ERROR (typed accessor / @batchKeyLifter / typed jOOQ TableRecord) replaces the previous two-option message. Model: new AccessorRef(parentBackingClass, methodName, elementClass) carries pre-resolved javapoet ClassName`s, sibling of `LifterRef; BatchKey.RecordParentBatchKey’s permit list grows from 2 to 4 (still permits `RowKeyed + LifterRowKeyed); both new permits delegate targetKeyColumns() to JoinStep.LiftedHop#targetColumns() so the DataLoader-key column tuple cannot diverge from the JOIN target columns. Builder-internal sealed hierarchy: RecordBatchKeyResolution.{Resolved, Rejected} lifts the per-field resolution into a sealed result the call site exhausts (Principle 8 "Builder-step results are sealed"); per-method AccessorMatch.{Single, Many, CardinalityMismatch} and call-result AccessorDerivation.{Ok, None, Ambiguous, CardinalityMismatch} capture the reflection match and reduction respectively, neither leaking past FieldBuilder (Principle 7 "Builder-internal sealed hierarchies for multi-target classification"). The accessor-arm rewrites the joinPath to [liftedHop] so SplitRowsMethodEmitter’s prelude reads target accessors uniformly through `JoinStep.WithTarget. Emitter: GeneratorUtils.buildRecordParentKeyExtraction’s switch grows from 2 to 4 arms; `buildAccessorRowKeySingle emits BackingClass) env.getSource(.<accessor>() followed by DSL.row(__elt.get<Pk>(), …​); buildAccessorRowKeyMany emits a typed for-loop over the accessor’s Iterable return building a List<RowN<…​>> for loader.loadMany. TypeFetcherGenerator.buildRecordBasedDataFetcher switches the loader value type to Record (1:1 with element-PK keys) and the dispatch to loader.loadMany(keys, Collections.nCopies(keys.size(), env)) when the BatchKey is AccessorRowKeyedMany; result type still follows the field’s GraphQL cardinality. SplitRowsMethodEmitter.buildForRecordTable routes AccessorRowKeyedMany through buildSingleMethod (1 record per key, scatterSingleByIdx) rather than buildListMethod; buildSingleMethod widens its first-hop cast from JoinStep.FkJoin to JoinStep.WithTarget with a conditional whereFilter lift, since both FkJoin (single-cardinality SplitTableField) and LiftedHop (loadMany-many) reach it. TypeFetcherGenerator.hasSingleSplitField widens to also gate scatterSingleByIdx emission on any RecordTableField carrying AccessorRowKeyedMany. The shared prelude’s pkCols switch in SplitRowsMethodEmitter.emitParentInputAndFkChain admits all four RecordParentBatchKey permits via targetKeyColumns() (lifter / accessor) and parentKeyColumns() (RowKeyed). Load-bearing keys: two new @LoadBearingClassifierCheck keys ; accessor-rowkey-shape-resolved (the producer guarantees the parent backing class, the accessor identity, and the element class are all reflectively confirmed before emitting AccessorRowKeyedSingle / AccessorRowKeyedMany; consumed by buildAccessorRowKeySingle / buildAccessorRowKeyMany which cast env.getSource() and invoke the accessor without instanceof or null guards) and accessor-rowkey-cardinality-matches-field (the producer pairs AccessorRowKeyedMany with list-cardinality fields and AccessorRowKeyedSingle with single-cardinality; consumed by buildRecordBasedDataFetcher’s `usesLoadMany ⇔ valueType = Record rule). LoadBearingGuaranteeAuditTest passes; no orphans. Test surface: unit-tier BatchKeyTest (5 cases, including a four-permit exhaustive-switch compile pin); pipeline-tier AccessorDerivedBatchKeyCase (6 cases ; list × list-accessor / list × set-accessor / single × single-accessor / ambiguous candidates / cardinality-mismatch / heterogeneous element); execution-tier AccessorDerivedBatchKeyTest runs the Many end-to-end, asserting one batched JDBC round-trip across two parents (3 element-PK keys), the (values (0, ?), (1, ?), (2, ?)) shape, and per-parent record redistribution. Deviations from spec: (a) Container enum on AccessorRowKeyedMany is preserved on the model but the for-loop iterates any Iterable, so emit no longer forks on it (acknowledged technical debt; tracked under R65 #1 follow-up); (b) Many rows-method routes through buildSingleMethod rather than a new shape, since loadMany is 1:1 record-per-key by contract; (c) loadMany overload requires List<Object> of key contexts, so the dispatch passes Collections.nCopies(keys.size(), env) and the batch loader reads keyContexts[0] as before; (d) Single permit is fully wired through the emitter but blocked at validate-time by Invariant #10 (single-cardinality RecordTableField rejection), so end-to-end execution-tier coverage waits for that gate to lift. Reviewer-pass follow-up: R65 accessor-batchkey-emitter-tightening filed for six architectural cleanups surfaced during the review (Container slot, hasSingleSplitField predicate union, prelude default → arm, two-classifier reflection-walk dedup, two-site dispatch fork in buildRecordBasedDataFetcher, unused ListAccessorOnSingleField fixture). Build green: mvn -f graphitron-rewrite/pom.xml install -Plocal-db SUCCESS on Java 25; 1247 unit/pipeline tests + execution tier all pass.

  • Lift @lookupKey partition onto TableInputArg (dml-lookup-key-partition-on-tableinputarg, R62, b4624f4): adds lookupKeyFields and setFields projections to ArgumentRef.InputTypeArg.TableInputArg, populated once via a new TableInputArg.of(…​) factory at the two construction sites (FieldBuilder.classifyArgument, MutationInputResolver.resolveInput). The narrow List<InputField.ColumnField> element type expresses the mutation-arm guarantee that DML inputs admit only Direct-extracted ColumnField; query-side TIAs simply contribute zero entries because @lookupKey lands only on a ColumnField. Three consumers drop their ad-hoc Set<String> rebuild: MutationInputResolver Invariant #4 reads setFields().isEmpty(); buildMutationUpdateFetcher walks tia.setFields() (no skip-during-walk, no cast); buildMutationUpsertFetcher walks tia.setFields() for the SET clause and reads !setFields().isEmpty() for the .doUpdate() / .doNothing() dispatch (the col/val lists still walk fields() to keep @lookupKey fields on the insert branch). dml-mutation-shape-guarantees consumer reliesOn strings updated to drop the "skip-the-set-during-walk" phrasing; LoadBearingGuaranteeAuditTest continues to pair the producer (FieldBuilder.buildDmlField) with the four emitter consumers. Pipeline coverage in GraphitronSchemaBuilderTest (UPDATE_TIA_PARTITIONS_FIELDS_INTO_LOOKUP_AND_SET, UPSERT_TIA_PARTITIONS_FIELDS_INTO_LOOKUP_AND_SET) asserts the typed projections land in declaration order; existing UPDATE / UPSERT execution-tier tests pass unchanged. Architectural follow-up to R22, surfaced in 181c28f.

  • Mutation bodies (mutations, R22, b699c5a + d792463 + 4dc4c04 + 2e9712e + 181c28f, plus pre-branch trunk history for Phase 1A / Phase 3 DELETE / Phase 6 service variants and the R50 cleanup pass): lifts all six mutation leaves out of TypeFetcherGenerator.STUBBED_VARIANTS ; MutationField.MutationInsertTableField, MutationUpdateTableField, MutationDeleteTableField, MutationUpsertTableField, MutationServiceTableField, MutationServiceRecordField. Highest-aggregate stub class going in (131 combined production rejections at the start of the work). Phase 1A (model + classifier): shared DmlTableField sealed supertype permits the four DML records, all sharing (parentTypeName, name, location, returnType, tableInputArg, encodeReturn, errorChannel); one FieldBuilder.classifyMutationInput(fieldDef, typeName) helper enforces Invariants #1 through #14 across all four DML verbs; the mutation-arm switch in classifyMutationField builds the appropriate variant record from the resolved tia + encodeReturn. The MutationField.DmlTableField lift and the four DML records actually shipped as part of R50’s cleanup pass (R50 deleted InputField.NodeIdField, NodeIdReferenceField, IdReferenceField, NodeIdInFilterField and folded their cross-table cases under ColumnReferenceField/CompositeColumnField; the post-R50 input-field shape is what the DML emitters consume). Phase 1B (model alignment, b699c5a): replaces the broad (returnType, encodeReturn, payloadAssembly) triple on DmlTableField with a single sealed DmlReturnExpression returnExpression slot. Five arms (EncodedSingle, EncodedList, ProjectedSingle, ProjectedList, Payload) cover exactly Invariant #14’s admitted return-type set; the Payload arm absorbs the R12-introduced Optional<PayloadAssembly>. Records went from 8 components to 6; emitters pattern-match a single sealed dispatch with no instanceof ScalarReturnType / wrapper().isList() / Optional.orElseThrow() / payloadAssembly().isPresent() predicates. New load-bearing key dml-mutation-shape-guarantees annotates FieldBuilder.buildDmlField (producer) and the four DML emitters (consumers); LoadBearingGuaranteeAuditTest enforces the pairing. Phase 2 (INSERT, d792463): buildMutationInsertFetcher plus the verb-neutral buildDmlFetcher skeleton (try/catch envelope, dsl chain, payload bind, returnSyncSuccess/catchArm) and the emitDmlReturnExpression projection terminator extracted from DELETE; column list and parallel values list both walk tia.fields() once, values use DSL.val(in.get(name), Tables.T.COL.getDataType()) for converter-mediated coercion. Execution-tier createFilm_insertsRowAndReturnsProjectedFilm against PostgreSQL verifies RETURNING $fields end-to-end, resolving the verification gap DELETE shipped with. Phase 3 (DELETE): shipped pre-branch on trunk; later retrofitted to the Phase 1B shape via the same buildDmlFetcher skeleton (buildMutationDeleteFetcher pattern-matches on f.returnExpression() via emitDeleteEncoded / emitDeleteProjected / emitDeletePayload helpers, no instanceof predicates). Phase 4 (UPDATE, 4dc4c04): buildMutationUpdateFetcher shares the same skeleton; SET clause walks tia.fields() skipping @lookupKey names, WHERE clause reuses buildLookupWhere. Execution-tier updateFilm_updatesRowAndReturnsProjectedFilm against PostgreSQL inserts a marker row, runs the mutation, asserts the SET clause wrote and RETURNING $fields returned the new title with languageId carrying through unchanged. Phase 5 (UPSERT, 2e9712e): buildMutationUpsertFetcher against the same skeleton; INSERT col/values lists walk tia.fields() once (every field, @lookupKey included), SET clause skips @lookupKey names, .onConflict(<keys>) reads from tia.fieldBindings(). Empty-SET case emits .doNothing() (jOOQ rejects .doUpdate() with no .set calls). UPSERT additionally carries an Oracle-dialect runtime guard (jOOQ silently translates INSERT …​ ON CONFLICT to MERGE INTO with semantics drift; jOOQ exposes no setting to disable the emulation). Two execution-tier tests cover both branches. Phase 6 (service mutations): shipped pre-branch on trunk. Both MutationServiceTableField and MutationServiceRecordField un-stubbed by delegating to the shared buildServiceFetcherCommon helper; the R12 §3 try/catch wrapper, §5 Jakarta validation pre-step, and §2c resultAssembly success-arm assembly all carry over for free on the mutation side. Both wear @DependsOnClassifierCheck(key = "service-catalog-strict-service-return", …​). Architectural follow-ups (181c28f): a post-Phase-5 review surfaced two model lifts that don’t gate the stub-lift work but tighten the model the emitters consume; promoted to standalone roadmap items (dml-lookup-key-partition-on-tableinputarg, R62; dml-dialect-requirement-on-model, R63) with the relevant design discussion preserved there. An adjacent finding from the same review (SplitRowsMethodEmitter.unsupportedReason returning Optional<Rejection.Deferred> only to have callers immediately call .message() to feed buildRuntimeStub’s `String parameter, dropping the typed EmitBlockReason) lives in R58’s domain rather than R22’s and is captured as runtime-stub-takes-deferred-rejection (R64). Out of scope and tracked separately: listed inputs (in: [FilmInput]), nested @table inputs (NestingField), @nodeId-typed input fields (NodeIdDecodeKeys-extracted ColumnField), ColumnReferenceField / CompositeColumnField / CompositeColumnReferenceField in mutation inputs (all gated as deferred at classify time), build-time INSERT column-coverage validation (deferred until jOOQ catalog reliably exposes NOT-NULL + default metadata), non-ID/non-TableBoundReturnType return types on DML fields (Int/Boolean/Connection<T> rejected at classify time; an AffectedCount arm on DmlReturnExpression is the future lift if needed), ScalarReturnType(ID) on non-@node tables (rejected with descriptive message), transaction wrapping (caller’s responsibility via dsl), non-PostgreSQL dialects (RETURNING and ON CONFLICT are Postgres-specific; UPSERT additionally carries the Oracle runtime guard), @mutation + @service mutual-exclusion (already rejected at classifier time). Build green: mvn -f graphitron-rewrite/pom.xml install -Plocal-db SUCCESS on Java 25.

  • Sharpen author-error messages with concrete remediations (sharpen-author-error-messages, R59, 14a5cce + 7003aa2 + cb20a25 + ce43469): four validator rejection messages gain concrete fix suggestions instead of stopping at the diagnosis. ServiceCatalog parameter-mismatch (ServiceCatalog.java:233-254): branches on zero-arg / one-arg / many-arg cases, pre-fills the actual sole arg name, and offers two remedies (rename the Java parameter, or bind via argMapping: "<javaParam>: <graphqlArg>" on @service); the empty-args branch suggests removing the parameter, adding a GraphQL argument, or registering a context key. MutationInputResolver listed-@table-input rejection (MutationInputResolver.java:249-255): names the supported single-non-list @table input wrapper shape and points at the bulk-mutation roadmap gap. FieldBuilder payload-multi-ctor rejection (FieldBuilder.java:1486-1492 and :1600-1606): lists found ctor signatures via the shared formatCtorSignatures helper and suggests record conversion or removing extras. BuildContext zero-FK / multi-FK rejections (BuildContext.java:528-548): zero-FK arm explains why a single-hop key won’t resolve and offers chain-via-intermediate or condition-based alternatives; multi-FK arm pre-fills the first FK name in a {key: …​} example. Re-classification: RecordTableField and RecordLookupTableField free-form-DTO rejections in FieldBuilder.classifyChildFieldOnResultType flip from RejectionKind.DEFERRED to AUTHOR_ERROR since R1’s @batchKeyLifter already closes them; the [deferred] prefix was misleading authors into thinking they’re blocked on a future release. Adjacent fix: SDL source paths in the validator’s gcc-style file:line:col: error: lines relativise against ctx.basedir() via GraphQLRewriteGenerator.relativiseSourceName so the path shrinks to the natural project-relative form (e.g. src/main/resources/schema/…​/sak.graphqls); falls back to the original string when the source is null, not absolute, or sits outside basedir. No structural changes to the validator pipeline; existing substring assertions still match (1219 unit-tier tests pass).

  • Multi-table interface / union fetchers (stub-interface-union-fetchers, R36, 033db82 + 201b57b + fcb04c6 + 171f605 + 8f0a0a4 + 33a0670): lifts six Field variants out of NOT_IMPLEMENTED_REASONS (QueryTableInterfaceField, ChildField.TableInterfaceField, QueryInterfaceField, QueryUnionField, ChildField.InterfaceField, ChildField.UnionField) into native SQL emission across two tracks. *Track A (single-table, discriminator-column shape): QueryTableInterfaceField and ChildField.TableInterfaceField emit a single SELECT against the discriminator-bearing parent table with per-participant LEFT JOINs gated on discriminatorColumn and per-occurrence aliases for cross-table @reference participant fields (lifted as ChildField.ParticipantColumnReferenceField); buildDiscriminatorFilter fires the discriminator predicates as a SQL IN (…​) clause; JooqCatalog.findColumn resolves SQL column names from logical names; selection-set gating uses graphql-java 25’s Type.field form. Track B (multi-table polymorphic, two-stage shape): new MultiTablePolymorphicEmitter produces a stage-1 narrow UNION ALL projecting (typename, pk0..pkN, sort) across participants, then dispatches stage-2 per typename through ValuesJoinRowBuilder (the same row-builder R55 collapsed for _entities / Query.nodes / @lookupKey) with explicit per-PK-slot t.<col>.eq(input.field(…​)) ON predicates. Composite PKs project DSL.jsonbArray(…​) as sort so cursor-decode round-trips via ConnectionHelperClassGenerator.encode/decode’s existing JSONB conversion; child fields auto-discover their FK join paths via `FieldBuilder.ctx.parsePath per (parentTable, participantTable) pair. Connection mode: root and child connections share buildStage1ConnectionBlock, which lifts the per-branch UNION ALL into a Table<?> pagesTable so the same derived-table reference backs the page query and the ConnectionResult totalCount. Child-connection emission uses a DataLoader-batched windowed CTE (buildBatchedConnectionFetcher plus buildBatchedConnectionRowsMethod): typed parentInput VALUES widens to Row<N+1> for composite-PK parents (capped at 22 to fit jOOQ’s typed Row22 ceiling), per-branch JOIN parentInput ON <participant>.<fk> = parentInput.<parent_pk> emits the position-aligned composite-FK AND-chain, and a ROW_NUMBER() OVER (PARTITION BY idx ORDER BY page.effectiveOrderBy()) outer filter caps each parent’s rows at page.limit(); per-parent ConnectionResult shares one pagesTable. The page-rows query collapses N parents to 1 SQL statement, asserted by addressOccupantsConnection_dataLoaderBatchesAcrossParents and projectItemsConnection_dataLoaderBatchesAcrossParents. Validator: validateMultiTableParticipants rejects PK-less participants and PK-arity mismatches; validateChildConnectionParentPk rejects empty parent PK and parent-PK arity > 21 as build-time AUTHOR_ERROR instead of codegen-time IllegalStateException. The earlier validateMultiTableConnectionConstraints (the arity-1 reject from B4a) deletes once Item 1 generalises connection mode to composite-PK participants via the JSONB sort key. TypeResolver wiring: GraphitronSchemaClassGenerator iterates non-Node InterfaceType / UnionType alphabetically and reads the synthetic __typename column projected by stage-1. Surface collapse (Item 3): the 5-arg and 6-arg emitConnectionMethods overloads and the per-parent inline branch retire (B4c-1 was promoted same-day to B4c-2’s batched form); the dispatcher in emitConnectionMethods is now a single parentTable != null switch on the 7-arg signature, called directly from TypeFetcherGenerator’s four interface / union arms. Test surface: unit-tier coverage in `TypeFetcherGeneratorTest, GraphitronSchemaClassGeneratorTest, InterfaceFieldValidationTest, and UnionFieldValidationTest (1219 total); execution-tier (233 total) covers cross-table participant fields, multi-table polymorphic root and child, connection pagination plus after-cursor plus totalCount plus inline-fragment dispatch, multi-parent DataLoader batching ratchet, composite-PK participants via paged_a / paged_b fixture, and composite-PK parents via synthetic project (org_id, project_id) with project_note / project_event children. Out of scope: mixed-PK-arity-or-type alignment beyond JSONB-encoded sort (PK column-name collisions across participants stay a follow-up); stage-1-as-CTE optimisation (the straight UNION ALL form is sufficient until profiling says otherwise); mixing with NodeId encoding for relay round-trip (per-field @nodeId projections continue in stage-2’s typed Record path); NodeIdReferenceField JOIN-projection form (R50 follow-up); Node interface TypeResolver (already wired via QueryNodeFetcher.registerTypeResolver). Priority number #3 is embedded in emitted reason strings consumed by existing schema authors and must stay stable. Build green: mvn -f graphitron-rewrite/pom.xml install -Plocal-db SUCCESS on Java 25; 1219 unit plus 233 execution all pass.

  • Argument-level @nodeId architectural tightenings (argument-level-nodeid, R40, 5064a16 + 9192bf7 + 5891293 + 9232887): the argument-level @nodeId machinery was already shipping correct user-visible behaviour out of R50; this item closes three structural seams a design review surfaced. Phase 1: Resolved.FkTarget splits into DirectFk / TranslatedFk sub-arms. The positional-match predicate between FK target columns and NodeType key columns moves from inline checks at two call sites (FieldBuilder.classifyArgument, BuildContext.classifyInputField) into the resolver itself, which picks the variant once. Both call-site projections (argument-side and input-field-side) consume the variant; the inline sameColumnsBySqlName check deletes from FieldBuilder. BuildContext.classifyInputField was previously not running the predicate at all, silently letting the pathological FK-target shape through; the lift closes that asymmetric-gating gap. The shared translatedFkRejectionReason method names the R57 hint substring. Phase 2: LookupValuesJoinEmitter.addRowBuildingCore branches the per-row decode site on CallSiteExtraction.NodeIdDecodeKeys. ThrowOnMismatch keeps the existing GraphqlErrorException for synthesised lookup-key paths where a wrong-type id is a contract violation; SkipMismatchedElement emits continue and tracks an effective row count, returning Arrays.copyOf(rows, effective) when shrunk. LookupMapping.LookupArg.DecodedRecord retypes its decode slot from HelperRef.Decode to CallSiteExtraction.NodeIdDecodeKeys so the failure-mode arm rides on the model. FieldBuilder.classifyArgument’s same-table arg arm flips from `Throw to Skip, restoring the originally-specified Skip semantics over the first pass’s expedient Throw; the implicit scalar-ID arm (no @nodeId, NodeId-backed table) keeps Throw for the synthesised lookup-key path. Phase 3: NodeIdArgPlan pre-resolves every @nodeId-decorated leaf reachable from a table-bound field’s argument set in one walk, threaded through resolveTableFieldComponents → classifyArguments → classifyArgument; the three previous walks (findSameTableNodeIdUnderAsConnection, walkInputTypeForSameTableNodeId, hasSameTableNodeIdAnywhere) collapse into reads of the plan. The @asConnection rejection, the lookup-promotion gate, and per-arg classification now share one classification pass instead of re-resolving each leaf three times. Load-bearing key: nodeid-fk.direct-fk-keys-match annotates NodeIdLeafResolver.resolve (producer) and three consumers (FieldBuilder.projectFilters, FieldBuilder.walkInputFieldConditions, BuildContext.classifyInputField); LoadBearingGuaranteeAuditTest picks up the pairing automatically. Test surface: pipeline-tier InputFieldFkTargetNodeIdCase.FK_TARGET_PATHOLOGICAL_KEY_MISMATCH_DEFERRED_INPUT covers the input-field-side asymmetric-gating closure; ArgumentSameTableNodeIdCase extraction assertions flipped from ThrowOnMismatch to SkipMismatchedElement. Resolver-tier NodeIdLeafResolverTest is the first resolver-tier unit test for an R6 resolver (DirectFk on matching keys, TranslatedFk on the parent_node + child_ref reproducer where the FK targets parent_node.alt_key but the NodeType key is parent_node.pk_id, DirectFk again on the input-field side); a new GraphitronSchemaBuilder.buildContextForTests seam exposes the wired BuildContext after type classification but before field classification. Execution-tier GraphQLQueryTest triplet (filmsByNodeIdArg_malformedIdMixedWithWellFormed_returnsWellFormedSubset, _allMalformedIds_returnsNoRows, _emptyList_returnsNoRows) covers the partial-decode skip path, the all-skipped short-circuit, and the empty-input edge. Out of scope: R57 (TranslatedFk JOIN-with-translation emission), multi-hop FK-target on the input side, mutation-key @nodeId args, and Record1 raw-cast template factoring. Full mvn install -Plocal-db clean.

  • @batchKeyLifter directive re-enables DataLoader batching on @record parents that lack catalog FK metadata (batchkey-lifter-directive, R1, 07e6954 + d12e60d + 4283fb2 + 7c284a1 + b5c6749 + a1a5e29): closes the two RecordTableField / RecordLookupTableField "requires a FK join path and a typed backing class for batch key extraction" deferred rejections in FieldBuilder.classifyChildFieldOnResultType for free-form DTO parents (PojoResultType and JavaRecordType with non-null fqClassName). The schema author supplies a static Java method that lifts a RowN<…​> batch key out of the parent DTO; the classifier reflects on it once at build time, validates the per-position column-class match against the directive’s targetColumns, and produces a BatchKey.LifterRowKeyed carrying a JoinStep.LiftedHop (target table + key columns, single-hop by construction) plus a LifterRef(ClassName, String) typed reference (sibling of MethodRef, shaped after R50’s HelperRef precedent). The emitter feeds the result into the existing column-keyed DataLoader path with no identity branching: target accessors come from a new JoinStep.WithTarget capability mixed in by FkJoin and LiftedHop; key extraction comes from the lifter call. Surface: new directive on FIELD_DEFINITION (@batchKeyLifter(lifter: ExternalCodeReference!, targetColumns: [String!]!)); new BatchKey.LifterRowKeyed permit (sealed hierarchy now five variants: RowKeyed, RecordKeyed, MappedRowKeyed, MappedRecordKeyed, LifterRowKeyed); new BatchKey.ParentKeyed and BatchKey.RecordParentBatchKey sealed sub-interfaces splitting the variant axis (the four catalog records expose parentKeyColumns() renamed from keyColumns(); LifterRowKeyed exposes targetKeyColumns() via the contained LiftedHop); the interface-level BatchKey.keyColumns() accessor removed (a shared accessor with variant-dependent meaning violated Sealed hierarchies over enums); new JoinStep.LiftedHop permit (sealed hierarchy now three variants: FkJoin, ConditionJoin, LiftedHop); new JoinStep.WithTarget capability mixed in by FkJoin and LiftedHop; new BatchKeyLifterDirectiveResolver standalone resolver, sibling to R6’s ten directive/projection resolvers, so classifier-side directive logic stays out of FieldBuilder. Renames and narrowings: GeneratorUtils.buildRecordKeyExtractionbuildRecordParentKeyExtraction, parameter narrowed from BatchKey to BatchKey.RecordParentBatchKey; GeneratorUtils.buildKeyExtraction parameter narrowed from BatchKey to BatchKey.ParentKeyed; both narrowings turn mis-routing of the @service-only permits into compile errors rather than runtime throws. TypeFetcherGenerator.buildRecordBasedDataFetcher no longer casts to BatchKey.RowKeyed. SplitRowsMethodEmitter.emitParentInputAndFkChain reads target accessors uniformly via the JoinStep.WithTarget capability; sealed-switch usage is reserved for the JOIN-on predicate (the genuine identity fork). The (JoinStep.FkJoin) and (BatchKey.RowKeyed) casts removed. Validator gate (Invariant #10, R1 Phase 2e): RecordTableField and RecordLookupTableField reject single-cardinality returns at validate time, promoting the previous SplitRowsMethodEmitter.unsupportedReason runtime stub to a build-time AUTHOR_ERROR. The stub is replaced by an IllegalStateException (post-validate reachability is a classifier bug). Emitter fix (Phase 2f): SplitRowsMethodEmitter.buildListMethod WHERE-filter loop unconditionally cast every JoinStep to FkJoin, throwing ClassCastException for LiftedHop paths. Fixed with if (!(path.get(i) instanceof JoinStep.FkJoin hop)) continue; ; LiftedHop carries no FK-side filter to apply, so the loop skips it. Load-bearing keys: per-fact @LoadBearingClassifierCheck / @DependsOnClassifierCheck pairs lifter-classifies-as-record-table-field and lifter-batchkey-is-lifterrowkeyed (both with producer pairs on BatchKeyLifterDirectiveResolver.resolve). The single-hop invariant is a structural model property (LifterRowKeyed holds one LiftedHop, not a list) documented in a plain javadoc comment on the rows-method prelude rather than as a keyed fact. LoadBearingGuaranteeAuditTest is unchanged. Test surface: BatchKeyLifterCase pipeline-tier coverage of the classifier matrix (POJO_PARENT_VALID_ROW1_LIST etc.) plus scalar-return rejection; MutationPayloadLifterTest execution-tier coverage with three CreateFilmPayload rows (languageId [1, 2, 1]) asserting one DataLoader dispatch with two distinct keys (DataLoader key-deduplication: 3 input rows → 2 batched VALUES tuples), the SQL contains language_id and "language" and (values (0, ?), (1, ?)), and per-parent Language lists resolve correctly (English × 2, Italian × 1). Hand-rolled fixture service (no DSLContext parameter) ensures the QUERY_COUNT == 1 assertion is clean ; the only JDBC round-trip is the lifter-batched language lookup. Documentation: rejection messages in FieldBuilder and ServiceCatalog.dtoSourcesRejectionReason now reference the live directive instead of the roadmap-file path; code-generation-triggers.adoc directive table gains a @batchKeyLifter row on the @record-parent child-fields table and the Source Map’s BatchKey / JoinStep permit listings updated for the new variants; rewrite-design-principles.adoc gains a "DTO-parent batching" subsection cross-linked from "Column value binding". Full mvn install -Plocal-db clean.

  • Load-bearing classifier guarantee audit annotations (load-bearing-guarantee-audit, R21, 9acdf3f): codifies the "classifier rejection becomes emitter assumption" pattern named in rewrite-design-principles.adoc § "Classifier guarantees shape emitter assumptions" as a runtime-discoverable annotation pair under no.sikt.graphitron.rewrite.model. LoadBearingClassifierCheck(key, description) marks the producer arm; DependsOnClassifierCheck(key, reliesOn) (repeatable via DependsOnClassifierChecks) marks each emitter site that relies on it. LoadBearingGuaranteeAuditTest walks target/classes under the rewrite package root, groups by key, and fails on (a) any consumer key without a matching producer, (b) any duplicate producer key, (c) blank description / reliesOn. A non-empty class-walk assertion guards against vacuous passes when the test is run before compile or from the wrong cwd. Producers without consumers are allowed (some checks reject for hygiene rather than because an emitter relies on them). The audit logic is exposed via package-private audit(Iterable<Class<?>>) returning List<AuditViolation> so a meta-test can exercise the failure-detection against a deliberate-violation fixture (auditfixture/OrphanedConsumer) without disturbing the production scan, keeping the audit’s own failure-detection durable across walker refactors. Sites annotated on landing: producers service-catalog-strict-tablemethod-return (ServiceCatalog.reflectTableMethod) and service-catalog-strict-service-return (ServiceCatalog.reflectServiceMethod) paired with TypeFetcherGenerator.buildQueryTableMethodFetcher / buildQueryServiceTableFetcher consumers; column-field-requires-table-backed-parent (FieldBuilder.classifyChildFieldOnTableType) paired with TypeFetcherGenerator.generateTypeSpec’s `case ChildField.ColumnField arm; error-channel.mappings-constant (FieldBuilder.resolveErrorChannel paired with ErrorMappingsClassGenerator.generate); plus the consumerless error-type.path-message-fields producer on TypeBuilder.buildErrorType. Inverse asymmetry (a new emitter that should depend on a guarantee but forgets @DependsOnClassifierCheck) is acknowledged out of scope: that drift mode falls back to the generated *Fetchers compile failure that the principles doc already names as the safety net. Documentation: rewrite-design-principles.adoc § "Classifier guarantees shape emitter assumptions" gains an enforcement paragraph naming the annotation pair and pointing forward at adding annotations on every new load-bearing classifier check. The annotation triple, audit test, fixture, and design-doc paragraph all landed as part of 9acdf3f (R12 §2c) because R12’s error-channel.mappings-constant was the first new live producer and the infrastructure was needed to gate it.

  • EntityFetcherDispatch per-typeId VALUES emission collapsed onto a shared row-builder (entityfetcherdispatch-lookup-pipeline-collapse, R55, 5aec7cd + 8ac503c + aee21f6): the typed Row<N+1> array, the arity-22 cap, the per-cell DSL.val(value, table.COL.getDataType()) construction, the DSL.values(rows).as(alias, "idx", "<sqlName>", …) aliasing, and the USING-args list now live in one place. New ValuesJoinRowBuilder helper (graphitron/src/main/java/no/sikt/graphitron/rewrite/generators/util/ValuesJoinRowBuilder.java) is consumed by both LookupValuesJoinEmitter (@lookupKey root and inline-child paths) and SelectMethodBody (federated entities plus Query.node / Query.nodes dispatch); the f-E SQL-shape pin (GraphQLQueryTest.nodes_perTypeIdBatch_emitsValuesJoinOrderByIdxShape) gates that the values / join / order by substring shape survives across both call sites. Helper API generic over caller slot: methods take List<S> plus Function<S, ColumnRef> projection plus a directive-context String (used in arity-cap and empty-slots error messages). The lookup site keeps its rich Slot record (argName, RootSource, decode bindings) and passes Slot::targetColumn; the dispatcher passes Function.identity() against List<ColumnRef>. The cellsCode value-expression callback receives the caller’s slot back, so no parallel-list bridge is required. Caller-local pieces (kept off the helper): the for-loop body that fills rows[i] (lookup site does composite-key extraction and the per-row DecodedRecord NodeId decode + GraphqlErrorException on null; dispatcher reads binding[0] / binding[1]); the idx cell expression (lookup uses DSL.inline(i), dispatcher uses DSL.val(idx, Integer.class), both render to a typed Field<Integer>); any extra projections beyond the join (the dispatcher’s DSL.inline("<TypeName>").as("__typename") and the materialised idxCol); the join syntax; and the .where(condition) / .orderBy(idxCol) chain. Reviewer-pass deltas (commit 8ac503c): the original change flipped the dispatcher to .using(…​) for symmetry with the lookup root path; the reviewer reverted it to .on(t.COL.eq(input.field("col", T.class)).and(…)) because the dispatcher’s SELECT projection comes from <TypeName>.$fields(env.getSelectionSet(), t, env) which references t.<col> directly, and USING collapses joined columns at render time, risking interactions with $fields-emitted projections that include the joined key columns themselves. The helper still exposes usingArgs for the lookup root path; the dispatcher’s join syntax is documented on SelectMethodBody’s class Javadoc. The `Condition condition = DSL.noCondition(); declaration sits before the join body in both call sites so the SELECT chain stays symmetric and gives R36 Track B (per-typename interface filters) and any future per-arm filter a hook to AND into; jOOQ folds noCondition() away at render time. Other reviewer-pass deltas: dropped a transitional ValuesJoinRowBuilder.Slot(ColumnRef) record that required parallel-list bookkeeping at the lookup site; added an empty-slots guard to rowTypeArgs (defensive ; both upstream classifiers already enforce non-empty key columns); arity-cap and empty-slots messages now embed a directive context ("@lookupKey" / "@key"), restoring the schema-author UX of the pre-collapse error messages; MAX_ARITY is package-private (tests are in-package, no external caller). Test surface: 16 ValuesJoinRowBuilderTest cases pin arity (1, 5, 21), the 22-cap with directive context, the empty-slots guard with directive context, alias args, USING args, both idx-cell shapes (lookup DSL.inline(i), dispatcher DSL.val(idx, Integer.class)), the rich-slot callback contract, and the Row<N+1>[] / Table<Record<N+1>> convenience helpers. The f-E regression test continues to pass; FederationEntitiesDispatchTest (16 cases) all green; GraphQLQueryTest (141 cases) all green. Line deltas: LookupValuesJoinEmitter 505 → 458, SelectMethodBody 164 → 159; new helper 185 lines + 196 lines of unit tests. Net diff −68 lines of generator code; net code-plus-tests +313. Downstream consumer: R36 Track B stage 2 (native multi-table polymorphism, shipped at ffa59e4) is the third caller of ValuesJoinRowBuilder, using the dispatcher-shape .on(…​) callsite per the same <TypeName>.$fields(…​) constraint. Follow-up nits (commit aee21f6): dispatcher’s ColumnRef projection switched from c → c to Function.identity() (singleton, saves one lambda allocation per emit); unused Slot lambda parameters renamed to in three places (the lookup site’s lambda still reads its slot param, so explicit naming stays there); spec design-section table for join syntax now ends with an inline pointer to "Reviewer pass deltas above" so a reader landing mid-doc isn’t misled by the original "Switch dispatcher to .using(…​)" decision.

  • FieldBuilder decomposed onto the cross-cutting-concern axis (decompose-fieldbuilder, R6, Phase 1 at 3f9b84c; Phases 2a/2b/2c at 201c2f0 + c819027 + 9e8fc46; Phase 5 at fd94f37; Phases 6a/6b/6c/6d/6e at 38b143c + 67c543d + 9766982 + 679c560 + 56cd3a0; Phase 7 at 42f8259; Phase 8 final mop-up at 84f4be7; review-driven shape tightening at cea16e0): the parent-context-first dispatch (classifyQueryField / classifyMutationField / classifyChildField*) survives as a thin orchestrator that calls a fixed pipeline of resolvers and projects each Resolved arm into the correct GraphitronField variant. Each cross-cutting concern lifted into its own resolver returning a sealed result, sibling to ArgumentRef’s sealed-variant pattern (the canonical example of Principle 7’s "builder-internal sealed hierarchies for multi-target classification" in `rewrite-design-principles.adoc). Directive resolvers (eliminate inline duplication and byte-identical rejection prose across classify arms): ServiceDirectiveResolver (@service, four-arm classify lift, sealed Resolved with Success.{TableBound, Result, Scalar} / ErrorsLifted / Rejected), TableMethodDirectiveResolver (@tableMethod, two-arm lift, Resolved.{TableBound, NonTableBound, Rejected} gated by isRoot), ExternalFieldDirectiveResolver (@externalField, single-arm lift, Resolved.{Success, Rejected}), LookupKeyDirectiveResolver (@lookupKey, three-arm lift, Resolved.{Ok, Rejected} with resolveAtRoot / resolveAtChild(_, withSplitQuery) entry points). Projection resolvers (lift bundled monoliths into focused units): OrderByResolver (~230 lines, sealed Resolved.{Ok, Rejected} owning the canonical @defaultOrder fallback message), LookupMappingResolver (pure projectForLookup ref-walker, total projection so no Resolved wrapper), PaginationResolver (clusters projectPaginationSpec + isPaginationArg + resolveDefaultFirstValue since they’re all pagination semantics), ConditionResolver (@condition resolution, two sealed result types ArgConditionResult / FieldConditionResult each {None, Ok, Rejected} replacing the prior dual-signal pattern), InputFieldResolver (plain-input-field classification wrapping BuildContext.classifyInputField), MutationInputResolver (DML @mutation input classification + return-type validation + @mutation(typeName:) parsing, Resolved.{Ok, Rejected}), EnumMappingResolver (the enum-mapping axis: buildTextEnumMapping / validateEnumFilter / deriveExtraction / enrichArgExtractions / buildLookupBindings, lifted last because the helpers fan out across argument classification rather than clustering with any single earlier phase). Final mop-up (Phase 8): conflict detection (detectQueryFieldConflict, detectChildFieldConflict) stays as private helpers on FieldBuilder since the methods are trivial single-call-site logic with no isolated test surface to gain; the remaining fb-coupled callbacks (buildWrapper, parseExternalRef, parseContextArguments, liftToErrorsField, fieldArgumentNames) likewise stay on FieldBuilder since migrating them to BuildContext would muddy that class’s schema/jOOQ-classification concern; eleven orphan imports (one model + ten BuildContext static imports left behind by the directive- and projection-axis lifts) removed, and fieldArgumentNames’s redundantly fully-qualified `Collectors / LinkedHashSet references collapsed onto the already-present imports. Review-driven shape tightening (cea16e0): five contained changes addressing dual-signal patterns and unused parameters surfaced by reviewing the lifts: LookupKeyDirectiveResolver drops unused (BuildContext, ServiceCatalog, FieldBuilder) constructor params; OrderByResolver consumes the classified ArgumentRef.OrderByArg directly instead of looking the GraphQLArgument up by name and re-walking the input type (drops 4 now-unreachable rejection arms + 3 orphan imports); EnumMappingResolver.validateEnumFilter’s null/fqcn/empty-string tri-state replaced with a sealed `EnumValidation.{NotEnum, Valid(fqcn), Mismatch(message)}; FieldBuilder.TableFieldComponents’s six-nullable-field record replaced with a sealed `{Ok, Rejected} interface (six consumer + five producer sites updated); MutationInputResolver lifts the @mutation(typeName:) raw String into a DmlKind enum + sealed DmlKindResult.{Absent, Kind, Unknown} (replaces 8+ string-equality comparisons across three sites with exhaustive enum switches and removes an "unreachable: typeName=…​" default arm). Net result: FieldBuilder shrinks from 3,301 lines to ~2,534 lines (-1,007 against the counterfactual no-R6 trajectory; live size also reflects R12’s concurrent growth) and ten resolver siblings live as standalone files (~240 / 148 / 117 / 90 / 289 / 88 / 109 / 176 / 73 / 317 / 276 lines respectively), each independently testable and aligned with the sealed-result pattern that ArgumentRef set as the precedent.

  • NodeId lifted out of the model (lift-nodeid-out-of-model, R50, phases d/e1/e2-foundation/e2-rest/e3/e4a/e4b/e4c at 7bf0303 + 2635d97 + b36f230 + 9e38cc9 + 1b07b72 + f2ba2c5 + 995bb29 + a923694; phases f-A/f-B/f-C/f-D/f-E at 67999cf + d43f1e3 + b21a152 + 8fe072f + 3298ac7; phases g-A/g-B/g-C at 29734fb + 6c54435 + e4ac4ee; encoder cleanup at 1275396 + 8a12231; status flip at 0b26872; post-review notes at 3459225; final retirements at fad83a7; R55 follow-on filed at 72ae5cc): wire-format encoding and decoding for @nodeId ids now lives at the DataFetcher boundary; the classifier model and emitted query builders below it see decoded key tuples and standard column predicates rather than NodeIdEncoder.hasIds(…​) calls reaching across the boundary. Wire-shape variants retired: InputField.NodeIdField / NodeIdReferenceField / NodeIdInFilterField / IdReferenceField, ChildField.NodeIdField / NodeIdReferenceField, BodyParam.NodeIdIn, LookupMapping.NodeIdMapping, ArgumentRef.ScalarArg.NodeIdArg. New boundary taxonomies: CallSiteExtraction.NodeIdDecodeKeys sealed into two arms (SkipMismatchedElement for filter call-sites where a malformed id short-circuits to "no row matches"; ThrowOnMismatch for lookup-key / mutation-key call-sites where a wrong-type id is a contract violation); CallSiteCompaction sealed root with Direct (plain projection) and NodeIdEncodeKeys(HelperRef.Encode) (encode-on-projection) arms; the third failure mode (NullOnMismatch for Query.node / Query.nodes / federated _entities) is dispatcher-driven rather than carrier-driven and lives in EntityFetcherDispatchClassGenerator. Composite-key column carriers: new InputField.CompositeColumnField / CompositeColumnReferenceField, ChildField.CompositeColumnField / CompositeColumnReferenceField, ArgumentRef.ScalarArg.CompositeColumnArg for arity > 1 NodeIds; arity-1 cases stay on the existing single-column carriers (which gain extraction / compaction slots). The Composite* variants narrow their boundary slot to the only arm the classifier produces (NodeIdDecodeKeys on input, NodeIdEncodeKeys on output) at the type system level rather than asserting via validator rule. BodyParam.ColumnPredicate sealed sub-taxonomy replaces the old ColumnEq(boolean list) shape with four predicate-arm records: Eq / In (single column) and RowEq / RowIn (composite-key tuples emitting DSL.row(c1, …​, cN).eq(…​) / .in(…​)). Lookup arg restructure: LookupMapping.ColumnMapping retypes from a flat List<LookupColumn> to List<LookupArg> sealed into ScalarLookupArg (single-key target with optional NodeId decode) / MapInput (composite-key Map input from R5’s @lookupKey) / DecodedRecord (composite-PK NodeId where the decode runs once per row at the arg layer); InputColumnBinding generalises from a flat record into a sealed split (MapBinding / RecordBinding), narrowly typed per arm so the source-shape homogeneity is type-enforced rather than validator-asserted. HelperRef new sealed sibling of MethodRef with separate Encode / Decode arms because the same List<ColumnRef> plays semantically different roles on each side (call-site Java parameter list vs returned RecordN<…​> shape); GraphitronType.NodeType gains pre-resolved encodeMethod / decodeMethod fields read by every emitter and the encoder generator from one source of truth. NodeIdEncoder API: per-Node-type encode<TypeName>(…​) / decode<TypeName>(String) → RecordN<T1..TN> helpers replace the generic encode("typeId", …​) / decode("typeId", …​) surface; peekTypeId(String) stays as the only generic public method (used by typeId-fanout sites); the generic encode / decodeValues bodies become private. Deleted: hasIds / hasId (query-builder helpers that did not belong in the encoder), coerceValue (the per-type decoders inline getDataType().convert(…​) per slot statically), canonicalize (no callers; Base64.getUrlDecoder accepts both padded and unpadded forms). Single-hop emission, two shapes: rooted-at-child (no JOIN, FK source columns are the keys) ships fully; rooted-at-parent (single-hop JOIN where FK source columns differ from target’s keyColumns) ships classifier-only with FetcherEmitter runtime stubs, deferred to R24’s expanded scope. Multi-hop FK and condition-join correlated-subquery emission stays in R24. MutationField.DmlTableField.nodeIdMeta retypes to encodeReturn: Optional<HelperRef.Encode> so the DML emitter no longer reconstructs the helper reference from a typeId string at emission time; JooqCatalog.NodeIdMetadata survives only as a classifier-time intermediate. Validator coverage lands one arm per new sealed variant (two NodeIdDecodeKeys, two CallSiteCompaction, four ColumnPredicate, three LookupArg, two InputColumnBinding, five Composite* carriers); TypeFetcherGenerator.NOT_DISPATCHED_LEAVES shrinks. Load-bearing classifier guarantees annotated at three keys (nodeid.decode.failure-mode, columnpredicate.column-arity, compaction.encode-keys) so emitter assumptions are tracked back to classifier sites. Fixture growth: nodeidfixture gains a composite-PK Bar Node type for [ID!] @nodeId(typeName: "Bar") row-IN coverage and a rooted-at-parent parent_node + child_ref shape (FK targets non-PK unique column) ready to drive R24’s emitter coverage. Test surface: every @nodeId execution test continues to round-trip (Query.node, Query.nodes, federated _entities, same-table filter, rooted-at-child reference, composite-PK lookup); SQL inspection via ExecuteListener confirms emitted bodies are c.eq / c.in / DSL.row(…​).eq / .in over decoded key tuples rather than encoded String ids; Query.nodes per-typeId batch SQL pinned to VALUES + JOIN + ORDER BY idx shape (regression test catches dispatcher fallback to legacy WHERE row-IN); failure-mode parity verified per arm. Post-review cleanup retired two compat fallbacks (InputField.NodeIdField and NodeIdReferenceField’s "classified-but-inert" arms in `BuildContext) that survived the wire-shape variant deletions; both reroute to InputFieldResolution.Unresolved with pointed AUTHOR_ERROR reasons. Coupling: R20 (IdReferenceField code generation) tombstoned and deleted in this transition (its execution-tier coverage is in R50’s pipeline test surface); R24 expanded to absorb rooted-at-parent single-hop JOIN-with-projection emission alongside its original multi-hop / condition-join scope; R55 filed as a Backlog item to collapse EntityFetcherDispatch’s bespoke per-typeId VALUES emission onto `LookupValuesJoinEmitter (the SQL shape was pinned by phase f-E but the two pipelines remain parallel). R40 (argument-level @nodeId) reduced to a small classifier-only follow-on. Inbound roadmap references in retire-synthesis-shims.md (R27) and faceted-search.md (R13) updated to name the post-R50 column-shaped successors. Stale {@link InputField.IdReferenceField#targetTypeName} javadoc in BuildContext and "R50 phase b2b" stub messages in FetcherEmitter / TypeFetcherGenerator retexted to point at R24.

  • Consolidated test-tier guide shipped (rewrite-test-tier-guide, R29, f621097 + bb83da6): four JUnit 5 meta-annotations (@UnitTier, @PipelineTier, @CompilationTier, @ExecutionTier) added to graphitron-fixtures main scope; applied to every @Test-bearing class in graphitron (87 classes) and graphitron-test (8 classes); GeneratorDeterminismTest carries @Tag("cross-cutting") directly as the sole cross-cutting test. Enforcement test added per-module: each walks its own target/test-classes tree and fails the build if any @Test-bearing class lacks exactly one tier identity. New graphitron-rewrite/docs/testing.adoc with decision rubric, per-tier sections, module-location vs. tier table, and build commands. Cross-links: rewrite-design-principles.adoc tier sections trimmed to one-liner pointers; docs/README.adoc Detailed reference list gains the new file; .claude/web-environment.md gains a one-line pointer. Javadoc sweep replaced "Level N" and mismatched tier prose with tier annotations across FieldValidationTestHelper, GraphitronSchemaBuilderTest, GeneratedSourcesSmokeTest, IdempotentWriterTest, and GeneratorDeterminismTest. Unblocks R25 rebalance-test-pyramid which depends on the canonical tier names. Review fixes (bb83da6): two broken xref: links to .claude/web-environment.md replaced with inline-code references (file is not staged in the AsciiDoc tree); ten ` — ` em-dash occurrences replaced with semicolons or colons per the CLAUDE.md writing convention.

  • graphitron-rewrite/docs/README.adoc lifted into an Architecture entry point (rewrite-docs-entrypoint, R28, 1b59f2e + ceb5cde): adds an eight-row module table, a six-stage end-to-end pipeline tour (RewriteSchemaLoaderGraphitronSchemaBuilderGraphitronSchemaValidatorGeneratorsJavaFile.writeToPath → consumer compile) naming the directives.graphqls injection-before-classification and orphan-sweep-after-every-emit ordering invariants that code-generation-triggers.adoc’s zoomed-in classification diagram leaves implicit, and a closing "Detailed reference" index. `workflow.adoc’s "Canonical path" gains a one-sentence pointer at `computed-field-with-reference in the changelog as a recent end-to-end exemplar. Per-module READMEs deliberately not maintained; the inline table is the orientation surface. Phase 1 (drop the inherited #4 numbering, add a real preamble) was absorbed by R9’s AsciiDoc migration.

  • ExternalCodeReference.argMapping for Java-param binding (external-code-reference-arg-mapping, R53, 4a6b731 + d120892): introduces a single canonical channel for naming the GraphQL→Java parameter binding on every method-backed call: @service, @tableMethod, and every @condition site (field-level, argument-level, input-field-level, path-step). The schema gains an argMapping: String field on ExternalCodeReference carrying a comma-separated javaParam: graphqlArg mini-DSL (target-on-left, matching the internal Map<javaTarget, graphqlSource> shape and the @experimental_constructType.selection convention). Unmentioned parameters bind by identity; whitespace and text-block input are tolerated. The R41 per-arg @field(name:) Java-binding semantic is retired in the same change without a deprecation cycle (R41 was unshipped to consumers); @field(name:) reverts to its column-binding axis on table-backed sites and its db-string mapping on ENUM_VALUE. ArgBindingMap collapses the previous forField / identityFor* family to one axis-agnostic factory of(Set<String>, Map<String, String>) returning sealed Result.{Ok, UnknownArgRef}; a new parser parseArgMapping(String) returns sealed ParsedArgMapping.{Ok, ParseError} and enforces unique Java targets, with order-preserving iteration via LinkedHashMap + Collections.unmodifiableMap. FieldBuilder.ExternalRef and BuildContext.ConditionDirective retype to carry argMapping and argMappingError separately from lookupError; failure precedence makes lookupError win over argMappingError so "I can’t resolve the class" reads ahead of "and your argMapping has a typo." Wire-through covers all seven reflect call sites: resolveServiceField, the two @tableMethod arms (root + child), buildArgCondition, buildFieldCondition, BuildContext.resolveConditionRef, and buildInputFieldCondition. Path-step @condition resolves with an empty slot set, so any non-empty argMapping rejects through UnknownArgRef; resolveConditionRef returns a new ConditionResolution(ref, error) record so the path-step caller surfaces the parser/typo with site context ("path-step @condition: …") rather than the previous generic "could not be resolved" message. Structural-inertness rejections route through the classifier rather than the schema validator (deviation from the plan, captured as an implementation note): parseExternalRef rejects argMapping on @externalField; TypeBuilder.buildResultType and buildNonTableInputType reject on @record; the enum-classify branch rejects on @enum. Tests across all upper tiers: new ArgBindingMapTest (15 cases covering parser empty/blank, duplicate Java target, malformed entry, missing colon, text-block input, factory identity baseline, override-claims-slot, two-overrides-binding-to-same-slot, unknown GraphQL arg, path-step empty + non-empty); GraphitronSchemaBuilderTest cases for happy-path, parser-rejected duplicate, pre-reflection unknown arg, post-reflection typo guard, structural-inertness rejections on @externalField/@record/@enum, plus four cross-axis cases (ARG_CONDITION_ARGMAPPING_DUAL_BOUND, FIELD_CONDITION_ARGMAPPING, TABLE_INPUT_FIELD_CONDITION_ARGMAPPING, CONDITION_PATH_ARGMAPPING_REJECTED) that R41’s per-arg design could not express; ServiceCatalogTest post-reflection error messages reframed from @field(name: "X") to argMapping entry 'X: Y'. Pipeline + execute coverage: filmsByServiceRenamed fixture (graphitron-test/src/main/resources/graphql/schema.graphqls) authored directly with argMapping: "filmIds: ids"; GraphQLQueryTest.queryServiceTable_filmsByServiceRenamed_overrideBindsArgToDifferentlyNamedJavaParam round-trips against PostgreSQL. R41 was tombstoned under the workflow’s Discarded terminal-state rule; R53 inherited R41’s reviewed design conclusions but ran its own Spec → Ready review cycle.

  • @service rows-method body ; first iteration (service-rows-method-body + R49 Phase B, befc156): replaces the previously-stubbed body emitted by buildServiceRowsMethod with a working call site. The shared emitter now handles both ServiceTableField and ServiceRecordField, walking MethodRef.params() to build the developer’s call: ParamSource.Sources → the loader’s keys parameter (passed through directly; element-shape conversion is a follow-up), ParamSource.DslContext → a dsl local declared from graphitronContext(env).getDslContext(env) when needed, ParamSource.Arg and ParamSource.Context via the existing buildArgExtraction and getContextArgument paths. ArgCallEmitter.buildMethodBackedCallArgs gains a 4-arg overload accepting a sourcesExpression CodeBlock; the legacy 3-arg overload delegates with null so root-level @service (where Sources is rejected at classifier time per Invariants §2) still throws when it sees the variant. buildServiceRowsMethod takes (BatchKeyField, MethodRef, ReturnTypeRef, perKeyType, parentTypeName, outputPackage); the dispatch site for both service variants threads the variant’s MethodRef through. FilmService.titleUppercase fixture switches from Set<Row1<Integer>> to Set<Record1<Integer>> (classifies as BatchKey.MappedRecordKeyed, framework emits Record1 keys via GeneratorUtils’s `RecordKeyed branch which uses Record.into(Tables.FILM.FILM_ID) for extraction); body fetches each film’s title from the film table and returns Map<Record1<Integer>, String> with uppercased values. Mental-model clarification: Row1<T> is jOOQ’s SQL-expression type for tuple-IN comparisons against the database, not an application-side artifact (no value accessor); Record1<T> extends Row1<T> and adds value1() for application reading. The framework’s continued emission of Row1 keys for the RowKeyed / MappedRowKeyed BatchKey variants is documented as a follow-up ; Set<TableRecord> / List<TableRecord> developer signatures still classify as those variants, so a dev choosing those shapes hits the same wall a Set<Row1<Integer>> dev would. Execution test GraphQLQueryTest.films_titleUppercase_resolvesViaServiceRecordFieldDataLoader exercises the full path: parent SELECT followed by one batched DataLoader round-trip resolving all five films' uppercased titles. R32’s spec body collapsed to a "shipped" pointer with the open follow-ups (element-shape conversion, the Row1 follow-up, strict-return-type validation against field.elementType(), the typed-context-value registry coordination) called out for tracking.

  • ServiceRecordField Phase A ; DataLoader plumbing for child @service with scalar / @record-backed return (service-record-field, b9a6900 + 87a827d + f9bf585 + 85974ac + Phase A close-out): lifts ChildField.ServiceRecordField out of TypeFetcherGenerator.NOT_IMPLEMENTED_REASONS into IMPLEMENTED_LEAVES. Phase B (R32) fills the rows-method body; Phase A ships the variant’s classification, BatchKey carrier, DataLoader registration, lambda + key-extraction emission, and a stub rows-method that throws UnsupportedOperationException at request time. Model: ChildField.ServiceRecordField gains a non-null BatchKey batchKey field and implements MethodBackedField, BatchKeyField; rowsMethodName() follows the same load<X> convention as ServiceTableField so the existing dispatch + key-extraction infrastructure picks the variant up via BatchKeyField pattern matching. elementType() accessor closes the deferral noted in set-parent-keys-on-service.md by deriving the per-key V from the schema directly: ResultReturnType with non-null fqClassName → backing class; ScalarReturnType → standard GraphQL scalar’s Java type (String / Boolean / Integer / Double / String for ID; custom scalars and enums fall back to String until the Phase B consumer scalar registry surfaces typed Java classes); other cases fall through to the reflected outer return on MethodRef.returnType(). Builder: Site 1 (classifyChildFieldOnResultType, @record-typed parent) scalar/record-return arms become RejectionKind.DEFERRED with a roadmap pointer (deriving the batch key would need lifting through the parent chain to a rooted @table, a separate design problem parallel to interface-union dispatch); Site 2 (classifyChildFieldOnTableType, @table-typed parent) lifts the BatchKey via the existing extractBatchKey(MethodRef) helper and constructs the variant. Validator: validateServiceRecordField rejects non-empty joinPath with RejectionKind.DEFERRED until the lift form ships. Generator: buildServiceDataFetcher and buildServiceRowsMethod are parameterised by (ReturnTypeRef, perKeyType) rather than the previous TableBoundReturnType+hard-coded RECORD; ServiceTableField passes RECORD, ServiceRecordField passes field.elementType(). Drive-by fixes uncovered by the first child-@service schema fixture in graphitron-test: dfe.getSelectionSet().getField(<name>) (non-existent API on DataFetchingFieldSelectionSet) → drop the sel extraction and the sel parameter from the rows-method signature (the Phase A stub throws and Phase B will reintroduce whatever shape its body needs); the loader-value-type fix that’s part of elementType() above (the previous RECORD hard-coding meant the generator was correct for ServiceTableField only ; ServiceRecordField would have shipped DataLoader<K, Map<K, V>> had the per-key-type lookup not been added in this Phase). Coverage: six TypeFetcherGeneratorTest cases assert the parameterisation along the new axis (positive scalar single + list, record-backed single, mapped factory selection, mapped rows-method return shape, positional rows-method return shape); two existing serviceField_* rows-method-signature tests are updated for the dropped sel parameter. Builder-tier coverage: GraphitronSchemaBuilderTest.NonTableParentCase.SERVICE_FIELD_ON_RESULT_TYPE flips from "ServiceRecordField" to "DEFERRED with @record-parent reason"; ServiceFieldValidationTest.RecordCase.NO_PATH expects no errors (variant is implemented) and WITH_LIFT_CONDITION expects the new joinPath DEFERRED rejection. New fixture: FilmService.titleUppercase(Set<Row1<Integer>>, DSLContext) → Map<Row1<Integer>, String> (Phase A signature only; body throws to mirror the generated rows-method’s stub) plus Film.titleUppercase: String @service(…​) on graphitron-test/schema.graphqls. The fixture compiles and is reachable from the schema; Phase B (R32) replaces the body and adds an end-to-end execution-tier test against PostgreSQL. Strict-return-type validation against field.elementType() is also Phase B’s deliverable since the structural unwrapping (Map<KeyType, V> vs List<V>) is the same logic Phase B’s body emitter encodes.

  • @externalField resolved-reference path → ComputedField shipped end-to-end (computed-field-with-reference, 137f9d2 + 8ca2c78 + 650de56 + 8a6685b): lifts ChildField.ComputedField out of TypeFetcherGenerator.NOT_IMPLEMENTED_REASONS into IMPLEMENTED_LEAVES. @externalField gains a mandatory reference: ExternalCodeReference! argument on the schema directive (matching @service, @tableMethod, @enum); graphql-java rejects no-arg use at parse time, so the classifier never sees a missing-arg case. New ARG_EXTERNAL_FIELD_REF = "reference" constant in BuildContext. ChildField.ComputedField gains a non-null MethodRef method field and implements MethodBackedField; the MethodRef.Basic carries the captured parameterised return type (Field<X>) as a structural TypeName and one Param.Typed at ParamSource.Table for the parent table parameter. New ServiceCatalog.reflectExternalField(className, methodName, parentTableClass) mirrors reflectTableMethod with a stricter contract: must be public static, must take exactly one parameter assignable from the parent’s jOOQ Table<?> class, must return parameterised org.jooq.Field<X> (raw Field is rejected). FieldBuilder’s `@externalField arm now parses the reference, runs an alias-collision check via JooqCatalog.findColumn (rejects when the GraphQL field name shadows a real SQL column on the parent @table), reflects the method, and constructs ComputedField with a populated MethodRef; all resolution failures surface as AUTHOR_ERROR. TypeClassGenerator.emitSelectionSwitch gains a ComputedField arm emitting case "<name>" → fields.add(<RefClass>.<method>(table).as("<name>")); to inline the developer’s call into the projection list; build$FieldsMethod and buildTypeSpec take a new computedFields parameter so the field actually reaches the switch (the previous flat collection only included ColumnField/NodeIdField/TableField/LookupTableField/NestingField). FetcherEmitter.dataFetcherValue gains a ComputedField arm emitting new ColumnFetcher<>(DSL.field("<name>")), reading by alias from the result Record. TypeFetcherGenerator’s dispatch arm becomes a no-op (wired by `FetcherEmitter, projected by TypeClassGenerator). validateComputedField rejects a non-empty joinPath (lift form) with RejectionKind.DEFERRED until the @reference path lands. Tests across all upper tiers: GraphitronSchemaBuilderTest.ComputedFieldCase extends SCALAR_RETURN to assert the resolved MethodRef shape (className, methodName, single Param.Table) plus new METHOD_NOT_FOUND (reflection failure surfaces AUTHOR_ERROR with the missing-method name) and NAME_COLLIDES_WITH_COLUMN (alias-collision rejection); ComputedFieldValidationTest NO_PATH flips to expect no errors (variant is now implemented), WITH_LIFT_CONDITION expects the new DEFERRED rejection; conflict-test fixtures at GraphitronSchemaBuilderTest:3802/3837 updated with reference: {…​} so the now-mandatory schema parses. New test fixture class TestExternalFieldStub provides the reflection target for the schema-builder tests; new fixture class FilmExtensions.isEnglish(Film) → Field<Boolean> in graphitron-fixtures provides the execution-tier target. New Film.isEnglish: Boolean @externalField(reference: {className, method}) field on graphitron-test/schema.graphqls; new GraphQLQueryTest.films_isEnglish_resolvesViaExternalFieldExpression end-to-end against PostgreSQL via -Plocal-db. Docs: code-generation-triggers.md line 171 reflects the new directive shape and code-emission contract; graphitron-lsp.md Phase 5 dispatch table extended with @externalField reference-argument completion as a tracked deliverable. The legacy no-arg form is not supported; downstream schemas (~49 known instances in Sikt projects) must add reference: { className: "…​", method: "…​" } when migrating to the rewrite.

  • Apollo Federation 2 entity dispatch via federation-jvm (federation-via-federation-jvm, 0014be7 + c964fc5 + 6898e78 + a200e94 + 55a9b37 + 558abc7 + c643ff6 + 09616d0 + 6e0904e + 040434e + 952a0dd + f35683b + 3cb65d8 + a7e71f4): Query._entities(representations: [_Any!]!): [_Entity]! now resolves natively for every type Graphitron classifies, with no per-consumer wiring beyond the existing Graphitron.buildSchema(…​) call. Classify-time model: new EntityResolution(typeName, table, alternatives, nodeTypeId) sidecar on GraphitronSchema.entitiesByType, populated by a new EntityResolutionBuilder that walks every @key-bearing or @node type after TypeBuilder / FieldBuilder. Each resolution carries one or more KeyAlternative(requiredFields, columns, resolvable, KeyShape) entries; KeyShape.NODE_ID is synthesised for every NodeType (decoded via NodeIdEncoder.decodeValues(typeId, id) at runtime), KeyShape.DIRECT is emitted for consumer-declared @key directives (rep field values map index-by-index to column values). @node + explicit @key(fields: "id", …​) dedups by promoting the consumer’s directive while pinning NODE_ID shape so the dispatcher still decodes through NodeIdEncoder rather than treating the literal "id" string as a column value; this preserves the documented resolvable: false opt-out. New FederationKeyFieldsParser rejects nested selections, dotted paths, aliases, arguments, variables, comments, and numeric values with targeted ParseException diagnostics; GraphQLSelectionParser is left untouched. Build-time SDL synthesis: new KeyNodeSynthesiser registry post-step (between FederationLinkApplier and TagApplier in loadAttributedRegistry) attaches @key(fields: "id", resolvable: true) to every @node type that does not already carry an explicit @key(fields: "id", …​), so the supergraph composer sees the entity declaration. Runtime emission: new EntityFetcherDispatchClassGenerator emits an EntityFetcherDispatch class with fetchEntities(env) / resolveByReps(reps, env) / resolveType(env) / typenameForTypeId(typeId). Per-rep flow walks alternatives in most-specific-first order, picks the first resolvable alternative whose requiredFields are all present in the rep, builds a per-rep DFE rebinding arguments to the rep so getTenantId(repEnv) resolves against the individual rep, decodes into a column-value row, and groups bindings by (alternative-index, tenantId) into nested LinkedHashMap`s. Per-group dispatch issues one SELECT per group via a `VALUES (idx, col1, col2, …​) JOIN <table> ORDER BY idx derived table; the idx column carries through SQL so result[row.idx] = r scatters rows back to original federation positions as a SQL property, not a Java post-processing step. Projection includes inline("Foo").as("typename") plus <TypeName>.$fields(env.getSelectionSet(), table, env); graphql-java’s DataFetchingFieldSelectionSet is type-scoped at the _entities DFE call site, so per-type $fields walks pick up only the inline fragment scoped to each typename (no cross-type batching needed). QueryNodeFetcher.rowsNodes and fetchById rewired to synthesise {typename, id} reps and call resolveByReps; the previous per-typeId loop and its canonicalize-encode-scatter round-trip disappear because idx carried through SQL preserves order directly and Base64.getUrlDecoder accepts both padded and unpadded forms. Schema wire-up replaces the placeholder fetchEntities / resolveEntityType lambdas in GraphitronSchemaClassGenerator’s two-arg `build() with EntityFetcherDispatch::fetchEntities / EntityFetcherDispatch::resolveType when entitiesByType is non-empty; otherwise the placeholder lambdas stay so a @link-but-no-entity schema still wraps cleanly. AppliedDirectiveEmitter.emitAstLiteralValue switches from per-scalar enumeration to ValuesResolver.valueToLiteral(…​) → AstPrinter.printAst → Parser.parseValue, eliminating a class of latent custom-scalar / Float / input-object / internally-coerced-enum bugs. ColumnRef adds columnClass() accessor used by the dispatcher to type the derived-table Row arity. Federated test fixture (graphitron-test/src/main/resources/graphql/federated-schema.graphqls) is isolated from the shared schema.graphqls via a second graphitron-maven execution generating into no.sikt.graphitron.generated.federated; non-federation tests keep their previous output package. Test coverage across three tiers: 11 EntityResolutionBuilderTest classify-time cases (NODE_ID synthesis with/without explicit @node(typeId:), DIRECT alternatives, multi-key, dedup, compound, resolvable: false carry-through, unresolvable-field demotion, nested-selection rejection, empty-fields rejection, non-@table rejection); 19 FederationKeyFieldsParserTest cases covering naked / braced / mixed whitespace / underscore-and-digit identifiers / commas / nested rejection / unbalanced braces / dotted / aliased / arguments / hash-comments / variables / numeric; 7 FederationBuildSmokeTest cases (two-arg shape, _Service + _entities field present, _Entity union membership, _Service.sdl carries synthesised @key(fields: "id") on every @node type, customizer invocation, one-arg → two-arg delegation); 16 FederationEntitiesDispatchTest end-to-end cases against PostgreSQL (single NODE_ID rep, mixed-typename order preservation, empty representations, unknown typename, garbage NodeId, DIRECT-shape via filmId, type-scoped selection-set per-type projection, multi-tenancy partition issuing one SELECT per tenant, multi-alternative dispatch per rep, typename-only projection, compound key, compound partial-match yielding null, compound batching one SELECT for multiple reps, customizer-replaces-default no-SELECT-fires, most-specific tie-break selecting compound over simple, non-resolvable @key yielding null without firing SELECT); plus 2 NoFederationRegressionTest cases asserting the shared fixture builds a non-federated schema and emits only the one-arg buildSchema overload. getting-started.md updated: @link intro broadened (a base schema { …​ } @link is also accepted), and the two-arg-form example reframed as an escape hatch for entity types Graphitron does not classify (custom fetchers must return jOOQ Record`s with a `typename column for the default resolveEntityType to recognise them). Hygiene pass shipped alongside: FEDERATION_DIRECTIVE_NAMES moved behind an initialisation-on-demand holder so federation-jvm load failures only surface on schemas that use federation; buildRecipeErrors mixed-error trade-off documented; the federation spec URL lifted from FederationLinkApplier.DEFAULT_FEDERATION_SPEC_URL to a new neutral FederationSpec class in the federation knowledge package (three callers no longer reach into a pipeline class for a constant); unused SchemaDirectiveRegistry.FEDERATION_DIRECTIVES and isFederation deleted (zero production callers; can be brought back if needed); federationLink threaded via a new AttributedRegistry(registry, federationLink) carrier returned from loadAttributedRegistry, so KeyNodeSynthesiser and GraphitronSchemaBuilder.buildBundle no longer re-walk the registry to discover what FederationLinkApplier.apply already determined, and FederationLinkApplier.hasFederationLink deletes. Non-goals: Federation 1, custom resolveEntityType extension point, @interfaceObject, TableInterfaceType as a federation entity, nested-selection @key, build-time _service.sdl artefact emission, cross-typename SQL union batching, cross-field DataLoader sharing into the entity dispatcher. 909 unit + 23 federation tests green.

  • Set<T> parent-keys on @service methods → MappedBatchLoader (set-parent-keys-on-service, eebf881): extends the BatchKey sealed hierarchy from two to four variants via the cross-product of container axis (List positional vs Set mapped) and key-shape axis (RowN vs RecordN): RowKeyed (existing), RecordKeyed (existing), MappedRowKeyed (new), MappedRecordKeyed (new). keyColumns() lifted to the sealed interface so generator switches can group by shape with multi-pattern arms without re-binding identifiers. ServiceCatalog.classifySourcesType replaces its List.class-only guard with a dual isList/isSet check and picks the variant from the two-axis cross-product; Set<TableRecord> classifies as MappedRowKeyed (matching how List<TableRecord> classifies as RowKeyed). dtoSourcesRejectionReason receives the same dual check so Set<SomePlainClass> now produces "not backed by a jOOQ TableRecord" instead of falling through to the generic "unrecognized sources type" path. TypeFetcherGenerator.buildServiceDataFetcher picks newMappedDataLoader vs newDataLoader from the variant and types the lambda’s keys parameter as Set<KeyType> vs List<KeyType> accordingly; drive-by fix: the existing positional path was calling DataLoaderFactory.newDataLoaderWithContext(…​) which does not exist on the API (the split-query path was already correct with newDataLoader). buildServiceRowsMethod returns Map<KeyType, List<Record>> / Map<KeyType, Record> for mapped variants and List<List<Record>> / List<Record> for positional; the data-fetcher return type stays CompletableFuture<V> in all four cases since loader.load(key, env) yields a per-key promise regardless of the underlying batch-loader shape. GeneratorUtils.keyElementType and buildKeyExtraction group by shape via multi-pattern arms. Tests: ServiceCatalogTest gains four reflectServiceMethod_setOf*Sources classification cases (TableRecord, Row1, Record1, DTO-rejection) plus a listOfRecord1 regression; TypeFetcherGeneratorTest gains serviceField_mapped* coverage of the Set/Map shapes and a regression for the newDataLoader fix. Unblocks production schemas that declare @service child fields with Set<SomeRecord> keys.

  • Same-table [ID!] @nodeId filter: primary-key IN predicate (3fdfbfa + 19180ea): a [ID!] @nodeId(typeName: T) field on a @table input type whose T resolves to the input’s own table now classifies as InputField.NodeIdInFilterField and emits NodeIdEncoder.hasIds("typeId", arg, table.col1, …​, table.colN), short-circuiting to DSL.noCondition() when the list is null or empty. BuildContext.classifyInputField adds a same-table guard before findUniqueFkToTable(t, t) (which would always miss for a self-FK lookup) and resolves nodeTypeId / nodeKeyColumns via the same three-tier fallback as NodeIdReferenceField: JooqCatalog.nodeIdMetadata first, then post-first-pass ctx.types, then SDL-only @node with the catalog primary key as a last resort. BodyParam migrates from a single record to a sealed interface with ColumnEq (existing scalar/IN path) and NodeIdIn (new) variants; TypeConditionsGenerator.buildConditionMethod switches on the variant and now takes outputPackage so it can fully-qualify the generated NodeIdEncoder reference. walkInputFieldConditions in FieldBuilder emits BodyParam.NodeIdIn for the new leaf, gated by lookupBoundNames so a future @lookupKey-bound combination still routes through LookupMapping.NodeIdMapping instead. ArgCallEmitter.buildNestedInputFieldExtraction now wraps the leaf cast in List<…​> when param.list() is true, fixing the call-site cast for list-shaped filter input fields (the spec assumed this already worked). TypeFetcherGenerator.NOT_DISPATCHED_LEAVES and GraphitronSchemaValidator register the new variant. Tests across three tiers: NodeIdPipelineTest.InputSameTableNodeIdCase (composite-PK, single-PK, target-not-@node Unresolved) using the nodeidfixture catalog because the same-table case requires _NODE_KEY_COLUMNS metadata that Sakila tables lack; TypeConditionsGeneratorTest (single-column, composite-column, list-of-String parameter type, mixed ColumnEq + NodeIdIn); GraphQLQueryTest.films_filteredBySameTableNodeId* end-to-end against PostgreSQL, asserting both that filtered IDs return exactly those rows and that an empty list passes through to noCondition() returning all rows. VariantCoverageTest.NO_CASE_REQUIRED carries an entry pointing at the pipeline test, parallel to how NodeIdField and NodeIdReferenceField are already handled. Cleanup pass dropped a dead nonNull field on BodyParam.NodeIdIn (the body always guards arg == null || arg.isEmpty() so outer-list nullability is moot).

  • Auto-emit Relay nodes(ids:) resolver when node(id:) exists (auto-nodes-relay-resolver, 71e439f + aa33bd3 + cbbc103 + 40e22b2 + 44d0201 + 6b865f3 + 4aa79f7): new QueryField.QueryNodesField sealed variant routed by FieldBuilder.classifyQueryField for any root-query field named nodes returning [Node] / [Node!] / [Node]! / [Node!]!; GraphitronSchemaValidator adds a no-op arm and TypeFetcherGenerator.buildQueryNodesFetcher emits a thin delegator to QueryNodeFetcher.getNodes parallel to the existing buildQueryNodeFetcher. QueryNodeFetcherClassGenerator extracts the per-typeId dispatch out of getNode into a private fetchById(env, id) helper reused by both single- and batch-paths, then adds a getNodes method that fans ids into per-tenant DataLoader<String, Record>`s keyed by `getTenantId(idEnv) + "/" + path, where idEnv is a per-id DataFetchingEnvironmentImpl.newDataFetchingEnvironment(env).arguments(Map.of("id", id)).build() so apps that vary tenant per id partition correctly (loaders share a registry across the request, so ids resolving to the same tenant batch into one hasIds query while ids from different tenants land in separate loaders; batchEnv.getKeyContextsList().get(0) is safe inside the batch lambda because every key in a given loader shares a tenant by construction). The batch-loader callback rowsNodes(keys, env) groups keys by peekTypeId, runs one dsl.select(…​).from(t).where(NodeIdEncoder.hasIds(typeId, typeIds, keyCols)).fetch() per typeId, and scatters rows back to original positions via a Map<String, List<Integer>> keyed by NodeIdEncoder.canonicalize(peekTypeId(id), id) so non-canonical inputs (padded base64, the URL decoder accepts trailing = while encode() emits the no-padding form) still match the canonical encoded id from the result row, eliminating a silent disagreement with node(id:). New NodeIdEncoder.canonicalize(typeId, base64Id) (decode + re-encode, null on malformed input or typeId mismatch) lives next to peekTypeId. The result-scatter projection always appends each nodeKey column to the $fields list (gated by if (!fields.contains(t.<col>)) to dedup against $fields’s `id-driven addition; mirrors TypeClassGenerator’s required-projection-columns pattern) and a synthetic `__typename column so the existing Node TypeResolver still routes by name. GraphitronContext.getTenantId javadoc tightened to spell out the tenant/DSLContext partition contract: when getDslContext varies per id, getTenantId MUST partition by the same key, since the loader picks one DSLContext from keyContextsList().get(0) for the entire batch. Generator-side comment in dispatchNodes documents that the registry is assumed request-scoped (the standard graphql-java pattern; cross-request reuse would let loaders and first-key contexts survive across calls and break tenant scoping). Test coverage: GraphitronSchemaBuilderTest.NODES_QUERY_FIELD classification case, QueryNodeFieldValidationTest no-op case, and 10 execution-tier GraphQLQueryTest cases under "Query.nodes ; Relay batch dispatch" (empty / mixed-type / garbage / unknown-typeId / missing-row / padded-base64 canonicalize regression / duplicate-ids / single-tenant QUERY_COUNT == 2 / per-tenant fan-out QUERY_COUNT == 2 / id-and-other-fields-together asserting both the response id field and the rowsNodes encode read from the same key column). 854 unit + 154 execution tests green.

  • IdReferenceField classifier + synthesis shim (20b3465 + afc11bc + 7fc28fe + a313040 + c594f0a + 37f01fc + ad6303b): new InputField.IdReferenceField sealed variant carries targetTypeName / fkName / qualifier / synthesized describing a filter predicate that resolves to a has<Qualifier>(s) method on the FK source’s jOOQ record class ; the shape KjerneJooqGenerator emits from a single FK out of the input’s resolved table. BuildContext.classifyInputField gains two arms between the existing scalar @nodeId branch and the @reference branch: the canonical form ([ID!] @nodeId(typeName: T) with optional @reference(path: [{key:}]) when the FK is ambiguous) resolves the FK via JooqCatalog.findUniqueFkToTable (new) or the explicit @reference key and emits IdReferenceField with synthesized=false; the synthesis-shim arm placed before column lookup intercepts legacy @field(name: "X_ID") and bare-name forms by reverse-mapping the column name through JooqCatalog.buildQualifierMap (new ; three lowercase keys per FK: raw qualifier, lowerCamel qualifier, plural lowerCamel qualifier; cached per source table) and synthesizes IdReferenceField with synthesized=true plus a per-site WARN whose message names parentTypeName.fieldName and the canonical @nodeId(typeName:) [@reference(path: [{key:}])] replacement that future migration tooling can parse out of build logs. Shim gate is catalog.nodeIdMetadata(targetTable).isPresent() ; the same KjerneJooqGenerator-project sentinel that gates the scalar NodeIdField shim. New catalog helpers: findUniqueFkToTable, buildQualifierMap, qualifierForFk, plus the package-private localGetQualifier reproduction of KjerneJooqGenerator.getQualifier (UpperCamelCase from role + targetTable + "_id"; generateRoleName returns "HAR" when source column equals target column, otherwise the role discriminator). New idreffixture schema (studieprogram + studierett, two FKs: HAR-role on studieprogram_id, role-prefixed registrar_studieprogram whose qualifier RegistrarStudieprogramStudieprogramId deliberately does not match any source column) wired through NodeIdFixtureGenerator.METADATA so the target studieprogram carries __NODE_TYPE_ID. Tests across three tiers: JooqCatalogIdRefTest (22 cases on Sakila + nodeidfixture + idreffixture for findUniqueFkToTable / buildQualifierMap / qualifierForFk / generateRoleName), IdReferenceShimClassificationTest (5 cases ; explicit @field(name:), bare plural, bare scalar, bare id: ID falls through to NodeIdField, role-prefixed where map key ≠ any source column), IdReferenceShimWarnFormatTest (4 cases ; parentType.fieldName format, FK1 + FK2 ambiguous canonical replacements both include @reference, single-FK unique replacement omits @reference), plus GraphitronSchemaBuilderTest.TableInputTypeCase cases on Sakila for canonical-form coverage and matching NodeIdPipelineTest updates. TypeFetcherGenerator.NOT_DISPATCHED_LEAVES registers the new variant; code generation lifts in a follow-up tracked at roadmap/id-reference-input-field.md (Spec). 853 unit tests green.

  • BatchKey.ObjectBased removed (batchkey-remove-objectbased): collapses the BatchKey sealed hierarchy to two variants (RowKeyed, RecordKeyed). ServiceCatalog.classifySourcesType splits the former single Class<?> arm: TableRecord<?> element types now classify as RowKeyed from the parent table’s PK columns via a new SourcesClassification sealed result type; non-TableRecord element types return DtoSourcesUnsupported and surface as UnclassifiedField with an error message naming the field, the sources parameter type, and the batchkey-lifter-directive.md backlog item. GeneratorUtils.keyElementType and buildKeyExtraction ObjectBased switch arms deleted; both switches are now exhaustive over two variants. GraphitronSchemaValidator.validateServiceTableField ObjectBased escape hatch (hasRowOrRecordKeyed early-return) deleted; the parent-table-PK check runs unconditionally. Test coverage: ServiceCatalogTest.tableRecordSources_classifiedAsRowKeyed and dtoSources_rejectedWithLifterDirectiveHint (classifier unit); ServiceFieldValidationTest.OBJECT_BASED rewritten as DTO_SOURCES_REJECTED asserting the rejection path; one pipeline case for the end-to-end DTO rejection. 747 unit tests green; full mvn install -Plocal-db clean.

  • Interface fetchers: selection-set-aware projection (3b982fc): replaces the unconditional table.asterisk() in buildQueryTableInterfaceFieldFetcher and buildTableInterfaceFieldFetcher with a runtime-built LinkedHashSet<Field<?>> fields populated with the discriminator column first (always, regardless of selection set) followed by addAll(<Participant>.$fields(env.getSelectionSet(), table, env)) per ParticipantRef.TableBound. The set deduplicates shared columns (e.g. title declared on both FilmContent and ShortContent collapses to one reference) and preserves insertion order; the .select(new ArrayList<>(fields)) substitution leaves the rest of the DSL chain (.from/.where/.orderBy/.fetch[One]) untouched. New participants: List<ParticipantRef> component on QueryField.QueryTableInterfaceField and ChildField.TableInterfaceField records, threaded by FieldBuilder from TableInterfaceType.participants() at classification time. New TypeFetcherGenerator.buildInterfaceFieldsList helper isolates the field-list emission from both fetcher variants. Six new TypeFetcherGeneratorTest cases (three per fetcher: _noAsterisk_inSelectClause, _discriminatorAlwaysSelected, _participants_emitFieldsCalls); existing tests + two validation tests updated for the record constructor change. 786 unit + 144 execution tests green. Cross-table participant fields (e.g. FilmContent.rating via JOIN to film) carved out as interface-cross-table-participant-fields.md; that follow-up will add the conditional LEFT JOIN gated on env.getSelectionSet().contains("TypeName/fieldName") plus the fixture additions (short_description on content, rating on FilmContent) needed to write its execution-tier tests.

  • runtime-extension-points.md rewritten for the rewrite runtime (13bbbb3 + 72dda8c): replaced the legacy graphitron-common description with the rewrite-emitted contract. The doc now opens with the per-app interface emitted under <outputPackage>.schema.GraphitronContext by GraphitronContextInterfaceGenerator, lists the three actual methods (getDslContext, getContextArgument, getTenantId), and shows the typed-key registration shape (b.put(GraphitronContext.class, ctx)) and the helper env.getGraphQlContext().get(GraphitronContext.class) from TypeFetcherGenerator.buildGraphitronContextHelper. New getTenantId section documents the previously-undocumented contract that Graphitron concatenates getTenantId(env) + "/" + path to build DataLoader registry keys (per TypeFetcherGenerator.buildDataLoaderName); only the tenant prefix is pluggable, the path component is Graphitron-controlled. New "Where each concern belongs" paragraph compares jOOQ Configuration (cross-cutting), getDslContext (per-request), and schema directives (SDL business semantics), absorbing the scope of the deleted graphitroncontext-extension-point-docs.md Backlog item. Wiring example lifted to a pointer at getting-started.md’s Hello World / Tenant-scoped `DSLContext / JWT-claim-context-arguments sections. "Complementary Technologies" coverage of jOOQ Configuration, ExecuteListener, and PostgreSQL RLS preserved. "See also" no longer points at graphitron-common/README.md.

  • Bump generator-side Java floor 21 → 25 (dec71d9): parent pom <release>21</release><release>25</release> plus a <requireJavaVersion>25</requireJavaVersion> enforcer rule alongside <requireMavenVersion>3.9</requireMavenVersion>; graphitron-test keeps its <release>17</release> output ratchet (the gap it now covers is "Java-18+ syntax leak" rather than "Java-21+"). Reviewer reproduced: full reactor mvn install -Plocal-db on JDK 25 (BUILD SUCCESS, all modules green); mvn -N validate on JDK 21 fails fast at enforce-versions with "Detected JDK …​ is version 21.0.10 which is not in the allowed range [25,)"; graphitron-lsp.md Phase 6 no longer owns the bump.

  • @asConnection totalCount field (b18b6a0 + 6fdd231): synthesised Connection types now carry totalCount: Int (nullable). ConnectionResult gains table and condition fields populated by the connection fetcher; the existing 2-arg (result, page) convenience constructor threads null, null for the Split-Connection path, and a new 4-arg (result, page, table, condition) constructor is called by buildQueryConnectionFetcher. ConnectionHelperClassGenerator emits a graphitronContext shim (mirroring the per-fetcher convention) and a totalCount(DataFetchingEnvironment) static resolver that runs dsl.selectCount().from(cr.table()).where(cr.condition()).fetchOne(0, Integer.class); graphql-java invokes it only when the client selects the field, so no count SQL is emitted on queries that omit totalCount. FetcherRegistrationsEmitter.connectionBody registers the totalCount coordinate gated on connectionType.schemaType().getFieldDefinition("totalCount"), so synthesised connections always wire it and structural connections wire it only when the SDL author declared the field; the incidental connectionTypeMap projection and unused ConnectionWiring record were removed in the same pass. GraphitronSchemaValidator.validateConnectionType rejects structural totalCount fields whose unwrapped type is not GraphQLInt, using the field’s SourceLocation (falling back to the type location for programmatic schemas) so watch-mode and IDE diagnostics highlight the exact line. Pipeline coverage: GraphitronSchemaBuilderTest.ConnectionTypeCase cases DIRECTIVE_DRIVEN_MINIMAL (synthesised carries nullable Int), STRUCTURAL_CONNECTION (null when absent), STRUCTURAL_CONNECTION_WITH_TOTALCOUNT (structural field preserved); ConnectionRegistrationsTest (synthesised registers, structural-with-Int registers, structural-without does not); ConnectionTypeValidationTest (6 cases covering Int, Int!, absent, String, [Int!]). Execution coverage in GraphQLQueryTest: filtered count equals row-predicate count, synthesised connection count, no select count SQL when field not selected (verified via a jOOQ ExecuteListener that records rendered statements), count SQL issued exactly once when selected. Two Backlog follow-ups filed alongside: totalCount for nested/Split-Connection carriers (returns null until that wiring ships), and count-only execution path (skip page query when only totalCount is selected).

  • @notGenerated directive removed from the supported set: FieldBuilder.classifyField short-circuits any application to UnclassifiedField with reason "`@notGenerated` is no longer supported. Remove the directive; fields must be fully described by the schema." The check runs before detectChildFieldConflict so co-occurring directives don’t shadow the no-longer-supported reason. The directive definition stays in directives.graphqls only so the GraphQL parser doesn’t fail with unknown directive before we emit our error. The NotGeneratedField sealed leaf, its validator dispatch, and the NotGeneratedField filters in TypeFetcherGenerator / FetcherRegistrationsEmitter are deleted. Input-field paths surface the same rejection: BuildContext.classifyInputField short-circuits to InputFieldResolution.Unresolved, which propagates through TypeBuilder.buildTableInputType and the nested-input recursion as an UnclassifiedType reason; FieldBuilder.classifyArgument pre-walks plain-input arg types and emits ArgumentRef.UnclassifiedArg so the surrounding query field becomes UnclassifiedField (necessary because projectFilters only surfaces per-field errors when a @condition / @lookupKey gate fires, so the previously-attempted condErrors entry was dead code). Silent-skip filters in TypeBuilder.buildInputType and the nested-input branch of BuildContext.classifyInputField are removed. Tests updated: NotGeneratedFieldValidationTest deleted; GraphitronSchemaBuilderTest.NotGeneratedFieldCase collapsed into a NOT_GENERATED_DIRECTIVE_REJECTED entry under UnclassifiedFieldCase; NOT_GENERATED_AND_SERVICE_CONFLICT (now subsumed by the short-circuit) deleted; new NOT_GENERATED_REJECTED_PLAIN_INPUT_ARG case under UnclassifiedFieldCase and NOT_GENERATED_REJECTED_TABLE_INPUT / NOT_GENERATED_REJECTED_NESTED_INPUT cases under TableInputTypeCase cover the input-field paths; notGeneratedField_isExcluded / fieldsMethod_excludesNotGeneratedFields deleted (their schemas no longer build). 736 rewrite unit tests green.

  • graphitron-rewrite:watch goal (8ae55b1 + 6bb5419 + review-fix): new WatchMojo in graphitron-rewrite-maven re-runs the rewrite generator on .graphqls changes; composes with content-idempotent writes so only the files whose rendered output actually changed are written and the IDE recompiles only the touched classes. Runs the generator once on startup (skippable via -Dgraphitron.watch.skipInitial=true), resolves the watch directory set from <schemaInputs> parents, and blocks on a SchemaWatcher event loop. SchemaWatcher walks each root recursively at startup and registers newly-created subdirectories on the fly; the Map<WatchKey, Path> registry is ConcurrentHashMap so the watch-loop thread (writes from dispatch on ENTRY_CREATE-for-directory) and the debounce thread (writes from addRoot on re-expanded <schemaInputs>) both touch it safely. Triggers route through a DebounceExecutor (default 300 ms, -Dgraphitron.watch.debounceMs) so a burst of saves coalesces into one regeneration. Validation failures and structural errors are caught and logged with the two-arg getLog().error(msg, throwable) form on both the initial run and watch-loop catch path; the loop survives. JVM shutdown hook closes the WatchService and debounce executor cleanly. Tests at graphitron-rewrite-maven/src/test/java/no/sikt/graphitron/rewrite/maven/watch/: 8 SchemaWatcherTest cases (write, modify, delete, debounce coalescing, non-.graphqls filter, recursive subdirectory registration, OVERFLOW dispatch, addRoot-vs-dispatch registry race) and 2 DebounceExecutorTest cases (burst-coalesces-to-one, close cancels pending). Documentation: new # Watch mode subsection in graphitron-rewrite/docs/getting-started.md.

  • Service-backed and method-backed root fetchers (c5f8497 + 787a8ae + 8f5ef71 + a0a6319 + b07eec6 + 0730b13 + 7d287f5 + 4d85a3c + 4616b67 + e874b88 + 01b040e + 5b2b87b + 9eae195): closes Stubs #7. QueryTableMethodTableField, QueryServiceTableField, QueryServiceRecordField lift out of TypeFetcherGenerator.NOT_IMPLEMENTED_REASONS into IMPLEMENTED_LEAVES. New ArgCallEmitter.buildMethodBackedCallArgs(MethodRef, CodeBlock, String) walks MethodRef.params() in declaration order with per-ParamSource emission (Arg via the existing extraction switch, Context via getContextArgument, DslContext as literal dsl, Table as the supplied Tables.FOO expression; Sources and SourceTable throw IllegalStateException since the classifier prevents them from reaching the emitter at root). Three new TypeFetcherGenerator per-leaf emitters: buildQueryTableMethodFetcher declares a specific-table local with no cast and projects via <Type>.$fields(…​), while buildServiceFetcherCommon (shared between buildQueryServiceTableFetcher and buildQueryServiceRecordFetcher) emits an optional dsl local plus a direct return ServiceClass.method(…​) with no projection (graphql-java’s column fetchers walk the records).

    Five classifier-time invariants enforce the strict-typed shape, all surfacing through `validateUnclassifiedField` as build-time errors. §1 and §2 share `FieldBuilder.validateRootServiceInvariants(ServiceResolution)`, called from both `classifyQueryField` and `classifyMutationField` so the mutation `@service` emitter (still in NOT_IMPLEMENTED_REASONS, lands under Stubs #4) inherits the root-shape constraints when it lifts. §1: Connection wrapper rejected on root `@service` / `@tableMethod`. §2: `ParamSource.Sources` parameter rejected at root (no parent context to batch against). §3: `@tableMethod` strict-class equality via `ClassName.equals` in `ServiceCatalog.reflectTableMethod` (rejects wider `Table<R>`); the emitter's no-cast local depends on this guarantee. §4: `DslContext` parameter supported only on `@service`. §5: strict `@service` return type via `TypeName.equals` in `ServiceCatalog.reflectServiceMethod` against `FieldBuilder.computeExpectedServiceReturnType(ReturnTypeRef)` (per-variant table covers `TableBoundReturnType` Single/List, `ResultReturnType` with non-null `fqClassName`, and skips for `ScalarReturnType` / `ResultReturnType` with null `fqClassName` / Connection-wrapped / child `@service` with non-empty `parentPkColumns`).
    `MethodRef.Basic.returnType()` is now a structured javapoet `TypeName` captured once via `TypeName.get(java.lang.reflect.Type)` at reflection time. Replaces a string-FQCN field plus a `parseTypeName` round-trip in `TypeFetcherGenerator` (deleted). Comparison is structural so wildcards (`? extends X`), array depth, and multi-arg generics participate in equality faithfully; the emitter declares matching fetcher return types directly without parsing strings or widening to `Object`. `ConditionFilter` overrides `returnType()` with a static `ClassName.get("org.jooq", "Condition")`. The pre-existing duplicate `ObjectBased` branches in `ServiceCatalog.classifySourcesType` collapsed to one in passing.
    Test fixture: `SampleQueryService` (graphitron-rewrite-fixtures) with `popularFilms(Film, Double) -> Film` (filters via `filmTable.where(...)`; jOOQ generated tables override every `where` / `as` / `rename` overload to return the specific subtype, so filtering inside `@tableMethod` is fully compatible with §3 strict-return), `filmsByService(DSLContext, List<Integer>) -> Result<FilmRecord>`, `filmCount(DSLContext) -> Integer`. Three SDL Query fields wire them via `@tableMethod` / `@service` directives.
    Coverage at every tier: 737 unit + 134 test-spec, all green. Three execution-tier cases in `GraphQLQueryTest` (filter-and-project with `QUERY_COUNT == 1`, service-table column-fetcher round-trip, service-record scalar coercion). Pipeline-tier negative cases in `GraphitronSchemaBuilderTest.UnclassifiedFieldCase` cover §1, §2, §3, §5 on both query and mutation arms. Unit-tier cases in `ServiceCatalogTest` pin the strict-validation comparison semantics (matching, mismatched raw class, mismatched inner generic, mismatched cardinality, null-expected, table-method matching/mismatched/wider/null). End-to-end `ServiceRootFetcherPipelineTest` asserts rejections surface as `ValidationError` through the full SDL → classifier → validator path.
      The "load-bearing classifier guarantees → tight emitter code" pattern (compile-time failure of the generated `*Fetchers` source as the safety net for any classifier/emitter mismatch) is codified in `rewrite-design-principles.md` ("Classifier guarantees shape emitter assumptions") with both this plan's `@tableMethod` no-cast local and the pre-existing `ColumnField` requires-table-parent check as named instances. Roadmap also gained a Backlog item for exploring how to map developer-declared checked exceptions on `@service` / `@tableMethod` methods to typed GraphQL errors (`@error` types, mutation payload error unions).
    - `@nodeId` + `@node` directive support (`a6f5a22` + `61e4dfe` + `09cf758` + `d5e0ed4` + `f77daf7` + `f403565` + `0218054` + `19916df`): Relay Global Object Identification, end-to-end. Plan rewritten to lead with semantics ; `typeId` is a wire-format contract; `@node` requires `implements Node`; SDL wins over jOOQ metadata when both speak; PK fallback fills in omitted `keyColumns`; metadata-only synthesis fires a deprecation diagnostic at type and field sites until consumers move to declared directives. `typeId` uniqueness is validated at classify time with symmetric demotion on collision. `Query.node(id: ID!)` lands as a generated `QueryNodeFetcher` class next to the per-type `*Fetchers` ; switches on the `typeId` prefix extracted via `NodeIdEncoder.peekTypeId`, projects each branch through the existing `<TypeName>.$fields(...)` plus a synthetic `__typename` column; a registered `Node` `TypeResolver` reads `__typename` to route the row to the matching concrete `GraphQLObjectType`. Encode + decode + WHERE-builder all live on the locally-emitted `NodeIdEncoder` (final, static-only ; no override hook); `LookupValuesJoinEmitter` switched off `no.sikt.graphql.NodeIdStrategy` so the rewrite tree no longer references `graphitron-common`. `ChildField.NodeIdReferenceField` emits the FK-mirror collapse path (single-hop FK whose target columns positionally match the target NodeType's `keyColumns`) ; encodes the parent's FK source columns directly, no JOIN. The legacy reflection machinery (`PlatformIdField` records, `hasPlatformIdAccessors`, `platformIdOutputMethodNames`, `sqlToAccessorSuffix`, related tests) is fully deleted. Test fixtures replace the hand-written `platformidfixture/` catalog with output from a custom `NodeIdFixtureGenerator` (extends the upstream `org.jooq.codegen.JavaGenerator`, hard-codes `__NODE_TYPE_ID` / `__NODE_KEY_COLUMNS` for `bar` (composite key) and `baz` (single key)) so the classifier is exercised against real generator output. 706 unit tests + 14 maven tests + 128 execution tests green; six `Query.node` execution cases cover round-trip, FK-mirror reference round-trip, unknown-typeId-null, garbage-base64-null, valid-prefix-no-row-null. Federation `_entities` sharing this dispatch path is superseded by the existing "Apollo Federation via federation-jvm transform" Backlog item. Two follow-ups remain on Cleanup: retire the synthesis shim once consumer SDL migrates, and lift `NodeIdReferenceField` into a JOIN-projection form for non-FK-mirror cases.
    - First-class Connection / Edge / PageInfo / PlainObject / Enum variants (`0aef2c7` + `0ecde9d` + `237d6d3` + `98021043` + `476bbee1` + `9a80a1d5` + `e352b60`): six-phase pivot to "classifier is authoritative." `GraphitronType` sealed hierarchy gains `ConnectionType`, `EdgeType`, `PageInfoType`, `PlainObjectType`, and `EnumType`, each carrying its `GraphQLNamedType schemaType` populated at classification time for both directive-driven (`@asConnection` on a bare list) and structural (hand-written Connection-shaped SDL) paths. `ConnectionSynthesis` (385 lines + 243-line test) deleted; `ObjectTypeGenerator`, `EnumTypeGenerator`, `InputTypeGenerator`, and `GraphitronSchemaClassGenerator` iterate `schema.types()` exclusively, with no `assembled.getAllTypesAsList()` fallback loops and no `hasAppliedDirective("asConnection")` probes at emit time. `GraphitronSchemaBuilder.rebuildAssembledForConnections` performs a two-step rebuild: `GraphQLSchema.newSchema(existing).additionalType(...)` registers synthesised Connection/Edge/PageInfo types, then `SchemaTransformer` rewrites `@asConnection` carrier fields (bare-list return type to Connection `typeRef`, appended `first` / `after` arguments) against the updated schema, so `assembled.getType("QueryStoresConnection")` resolves and the assembled schema agrees with the model. `FieldWrapper.Connection` shrinks to `(connectionNullable, defaultPageSize)` per-site metadata; per-type metadata lives on `ConnectionType`. Phase 7 (common `schemaType()` accessor) skipped with documented rationale: five variants carry the field but consumers are specialised switches; lifting an accessor would force ~15 unrelated domain variants to carry an unused `GraphQLNamedType` for the payoff of removing ~7 `instanceof` lines. One latent bug surfaced by Phase 6's enum flip: `FieldBuilder.classifyArgument`'s loose `ctx.types.containsKey(typeName)` guard misfired on enum-typed arguments once enums entered the model; tightened to `instanceof InputType || (UnclassifiedType && GraphQLInputObjectType)`. `InputDirectiveInputTypes.NAMES` (`ErrorHandler`, `ReferencesForType`, `FieldSort`, `ExternalCodeReference`, `ReferenceElement`) skipped at classify time so they never enter `schema.types()`; `_`-prefix guard moved above the `GraphQLEnumType` branch in `TypeBuilder.classifyType`. Coverage: six `ConnectionTypeCase` classification tests, `connectionType_directVariant_emitsFieldsFromSchemaType` in `ObjectTypeGeneratorTest` (constructs `ConnectionType` / `EdgeType` records directly so an emitter bug cannot be masked by classification), `VariantCoverageTest` cases for the new variants, snapshot diffs on the test-spec `schema/` output (zero diff on Phases 4 / 6; Phase 5 expected drift from the assembled rebuild). Supersedes the `ConnectionSynthesis` entry below; the totalCount entry above builds on `connectionType.schemaType()`.
    - `79af12c` ; Rewrite owns `@asConnection` via emit-time synthesis: `ConnectionSynthesis.buildPlan()` scans the assembled `GraphQLSchema` for `@asConnection` on bare-list fields and produces a `Plan` (connection name to `ConnectionDef` map, `needPageInfo` flag) without touching the registry. `emitSupportingTypes()` turns the plan into sorted `TypeSpec` lists: `<ConnName>Type` and `<ConnName>EdgeType` each carry `type()` + `registerFetchers()` (bound to `ConnectionHelper`), and `PageInfoType` is synthesised when absent. `ObjectTypeGenerator.buildFieldDefinition()` rewrites directive-driven fields: replaces the bare-list return type with a `typeRef` to the synthesised Connection name and appends `first: Int = <default>` / `after: String` arguments. `GraphitronSchemaClassGenerator.generate()` wires synthesised Connection/Edge/PageInfo types into `GraphQLSchema.build()` via `.additionalType(...)`. `GraphQLRewriteGenerator.runPipeline()` emits the synthesised `TypeSpec` files to the schema sub-package. Fixture adds `stores: [Store!]! @asConnection` producing `QueryStoresConnectionType` + `QueryStoresEdgeType`; smoke test verifies both are loadable; two execution tests cover cursor pagination round-trip over the Sakila stores. Structural (hand-written) Connection types are unaffected. 122 pipeline/execution tests green; 32 new unit tests across `ConnectionSynthesisTest`, `ObjectTypeGeneratorTest`, `GraphitronSchemaClassGeneratorTest`.
    - Content-idempotent writes + stale-file sweep (`5c780fb` + `9526217` + `84b0af7`): `GraphQLRewriteGenerator.write()` switched from `writeTo(File)` (void, always-overwrite) to `writeToPath(Path, StandardCharsets.UTF_8)`, which skips disk writes when a SHA-256 comparison against the existing file matches (logic lives in the forked `no.sikt.graphitron.javapoet.JavaFile`). Each emitted `Path` is collected into a `Set<Path> emittedThisRun`; `sweepOrphans()` walks the six owned sub-packages non-recursively (`""` / `util` / `schema` / `types` / `conditions` / `fetchers` under `outputDirectory`), deletes any `*.java` file not in the set, and leaves everything outside those sub-packages alone. Ratchets: pipeline-tier `GeneratorDeterminismTest` in `graphitron-rewrite-test` runs the full generator against the 448-line fixture schema twice (once into two different output dirs, asserting byte-identical trees; once against the same output dir, asserting mtimes preserved); writer-tier `IdempotentWriterTest` in `graphitron-rewrite` covers tamper-detection, orphan sweep inside owned sub-packages, and scope preservation outside owned sub-packages against a trivial two-type SDL. Docs: new `## Dev loop` section in `graphitron-rewrite/docs/getting-started.md` documents the three-clause contract (determinism, minimal-change writes, clean removal) in developer-observable terms plus IntelliJ / Quarkus / Spring Boot DevTools interop. Determinism audit (grep) came back clean: zero `System.currentTimeMillis` / `Instant.now` / `UUID.randomUUID` / `System.nanoTime` in generator source, zero `hashCode`-keyed comparators, `fetcherBodies` uses `TreeMap` (stable ordering), one bare `HashMap` in `JoinPathEmitter.generateAliases` but it's a counter that is never iterated. Legacy-coexistence risk audit: rewrite-test migrated to `graphitron-rewrite-maven` during the Maven-plugin landing so no in-repo consumer has both generators active; external-consumer collision is the caller's audit to perform against their own `<outputPackage>` layout.
    - Self-contained rewrite aggregator build (`7df7638` + `aa0f0b7` + `7da16e7`): `mvn install -f graphitron-rewrite/pom.xml` on a clean empty local repo builds all five rewrite modules without resolving any legacy `graphitron-*` artifact. `7df7638` dropped `<module>graphitron-rewrite</module>` from the root reactor; `aa0f0b7` reparented `graphitron-rewrite-parent` off `graphitron-parent` with inlined dependencyManagement / pluginManagement / compiler (release=21) / enforcer / quick-profile blocks, and replaced `${revision}${changelist}` with hardcoded `9-SNAPSHOT` across the rewrite tree (sign-off accepted; rewrite-tree bumps are now a five-pom grep-replace). `7da16e7` forked `graphitron-javapoet` into `graphitron-rewrite/graphitron-javapoet/` under coord `no.sikt:graphitron-rewrite-javapoet` (package unchanged so rewrite-core imports are untouched; legacy copy byte-identical); swapped rewrite-main's dep; dropped a dead `graphitron-common` compile dep from `graphitron-rewrite-test` (no Java imports resolved through it) and replaced its transitive `graphql-java` path with an explicit test-scope dep on `rewrite-test`; shipped `graphitron-rewrite/scripts/verify-standalone-build.sh` that runs the aggregator against a fresh empty `mktemp -d` local repo and greps the resulting repo for forbidden coords (`graphitron-common`, `graphitron-java-codegen`, `graphitron-maven-plugin`, `graphitron-schema-transform`, legacy `graphitron-javapoet`); updated `claude-code-web-environment.md`, `rewrite-design-principles.md`, and root `README.md` to name the aggregator-local entry point. Absorbs the former Cleanup-section entry "Drop `graphitron-common` build dependency from `graphitron-rewrite`" (entry deleted from Cleanup). 695 rewrite-core unit tests green, 116 execution-tier tests green, 2 Invoker ITs green, legacy root reactor byte-identical.
    - Rewrite owns schema loading + directive auto-injection (`c31771d`): `RewriteSchemaLoader` at `no.sikt.graphitron.rewrite.schema` parses user schema paths via `MultiSourceReader` with auto-injection of a rewrite-local `directives.graphqls` (292-line copy of the canonical from `graphitron-common`). Filesystem-only for user sources; `SchemaParser.buildRegistry` over `MultiSourceReader` with `trackData(true)`. Switches `GraphQLRewriteGenerator` and `TestSchemaHelper` off `SchemaReadingHelper`; drops `graphitron-common` build dep from `graphitron-rewrite/graphitron-rewrite/pom.xml` (declares `graphql-java` directly). Consumer-pom fix: `graphitron-rewrite-test` dropped its `<transform>` execution (which embedded directive declarations in the assembled schema, clashing on parse with auto-injection) and pointed `<schemaFiles>` at the raw user schema. `RewriteSchemaLoaderTest` covers: two-file fixture load, `@table` auto-injection proof, missing-source error, and reader-close verification. Absorbs the Cleanup entry "Drop `graphitron-common` build dependency from `graphitron-rewrite`".
    - Rewrite-owned Maven plugin (`76754b3` + `8a8c5ef` + `17504dd` + review-round-2 `6026b98` + `388065b`): new `graphitron-rewrite-maven` module with `GenerateMojo` / `ValidateMojo` driven by `AbstractRewriteMojo` (5 `@Parameter` fields post-cleanup: `schemaInputs`, `outputDirectory`, `outputPackage`, `jooqPackage`, `namedReferences`), `SchemaInputExpander` (glob expansion via Plexus `DirectoryScanner`, fail-fast on zero matches, `RuntimeException`-wide catch), and `RewriteContext` defensive-copy record. `graphitron-rewrite-test/pom.xml` migrated off the legacy plugin; `enableRewrite`/`disableLegacy`/`failOnRewriteValidationError` flags removed. 14 unit tests (GenerateMojoTest, SchemaInputExpanderTest, RewriteContextTest) and 2 Maven Invoker ITs (`basic-generate` happy path, `missing-schema-inputs` fail-fast). CI-friendly parent POM antrun workaround documented in plugin pom. Review-round 2 cuts (`6026b98`): `<scalars>` / `<maxAllowedPageSize>` excised (both were silent-no-op on the config surface with zero consumers in rewrite core); `<outputDirectory>` normalised against `project.basedir` instead of CWD; `mvn graphitron-rewrite:validate` works standalone from the CLI (validate-only path substitutes an inert package sentinel so the classifier type-checks); `AbstractRewriteMojo.runGenerator` unifies the `RuntimeException` → `MojoExecutionException` wrap so both Mojos share one error envelope. Generator cleanup (`388065b`): `GraphQLRewriteGenerator` extracts `logWarnings` and `validateAndLogErrors` helpers, drops stale legacy-Mojo javadoc on the instance ctor.
    - Rewrite owns tagged schema inputs + description notes (`84cfd644` + `8adaaa5e` + `a937d2d1`): introduces `SchemaInput` record (sourceName + optional tag + optional descriptionNote), `SchemaInputAttribution` with fail-fast overlap check, and a `RewriteContext` record carrying `schemaInputs` + `basedir`. `TagApplier` applies `@tag(name: "<tag>")` to fields / input fields / enum values / arguments / unions (legacy parity), auto-injecting the Apollo-federation-compatible `@tag` directive declaration when the registry has none and skipping elements that already declare `@tag`. `DescriptionNoteApplier` applies a blank-line-separated note (platform-stable literal `\n\n`) to everything `TagApplier` touches plus the type declarations themselves per D2 (widened past legacy for object / interface / enum / input). `GraphQLRewriteGenerator` gains an instance `run()` entry point layering the appliers between loader and classifier; static `generate()` stays intact so the legacy Mojo keeps driving `graphitron-rewrite-test`. D2 resolved as "widen notes, keep tags narrow"; naming deviation from plan (instance method `run()` not `generate()`) because Java forbids static + instance overload on one signature ; Maven-plugin plan unifies onto one name when the static retires. Review-round 1 (`8adaaa5e`) surfaced two latent production bugs: `ObjectTypeDefinition.transform()` on an `ObjectTypeExtensionDefinition` returns a plain base definition (fixed by adding extension arms to each applier's switch calling `transformExtension(...)`), and `MultiSourceReader`'s line-terminator-based source-name tracking bleeds the last line of an unterminated input into the next source (fixed in `RewriteSchemaLoader` with a `terminated()` Reader wrapper that emits a final `\n` only when the inner stream did not). Review-round 2 (`a937d2d1`) pinned both fixes: `RewriteSchemaLoaderTest.unterminatedFirstSourceDoesNotBleedSourceNameIntoSecond` ratchet with raw-string fixture; four extension tests per applier (Interface / InputObject / Enum / Union mirroring the original Object case); and an F3 follow-up that suppresses the synthetic `\n` when the source already ends with one, so `SourceLocation.line` in parse-error diagnostics is not shifted by a synthetic trailing blank. Tests: 695 rewrite-core green (from 653 pre-landing; +31 new in the applier + pipeline suites, +9 in the review-round-2 pin, +2 in the latent-bug surfaces).
    - Graphitron emits a prebuilt programmatic `GraphQLSchema` (`81fa607` + `5b4ecce` -> `4088cb1` + `dabfba3` + `9b4622e`): three-commit replacement of the emitted `Graphitron.java` facade's SDL + `RuntimeWiring` assembly with a single `buildSchema(Consumer<GraphQLSchema.Builder>)` call that returns a fully wired schema. Commit A retargets `GraphitronContext` into `<outputPackage>.rewrite.schema.GraphitronContext` and switches the `graphQLContext` key from `"graphitronContext"` to `GraphitronContext.class`. Commit B lands new `<TypeName>Type` generators (enum / input / object / interface / union) in `<outputPackage>.rewrite.schema`, a `GraphitronSchema` assembler owning the shared `GraphQLCodeRegistry.Builder`, the new `Graphitron` facade, survivor-directive definitions via `additionalDirective(...)` + applications via `AppliedDirectiveEmitter` on every type / field / argument / input-field / enum-value builder, default-value round-trip via `.defaultValueProgrammatic(...)`, and a legacy-wiring bridge that keeps old emitters live during the transition. Commit C deletes `WiringClassGenerator`, `GraphitronWiringClassGenerator`, the legacy `<TypeName>Wiring` classes, and the `GraphitronWiring` aggregator; the bridge is replaced by a new `FetcherRegistrationsEmitter` that emits `codeRegistry.dataFetcher(FieldCoordinates.coordinates(type, field), value)` bodies directly into the `<TypeName>Type.registerFetchers` method; `GraphitronSchemaValidator.validateNotGeneratedField` rejects `@notGenerated` with the plan-specified error; `GeneratedSourcesLintTest.emittedSourcesDoNotImportLegacyRuntimeTypes` ratchets against FQN imports of `RuntimeWiring`, `TypeRuntimeWiring`, `SchemaGenerator`, `SchemaReadingHelper`, and upstream `no.sikt.graphql.GraphitronContext`. Three execution-tier fallout fixes landed with C: typed `(DataFetchingEnvironment env)` lambda params disambiguating the `DataFetcher` / `DataFetcherFactory` overloads on `GraphQLCodeRegistry.Builder.dataFetcher`, five `.additionalType(Scalars.GraphQLInt)`-and-friends calls in `GraphitronSchema.build` (programmatic schema doesn't auto-register built-in scalars the way `SchemaGenerator` does for SDL), and `.value(name)` alongside `.name(name)` on every enum value so graphql-java's Coercing layer doesn't reject string-matching-enum-name serializations. `graphitron-rewrite/docs/getting-started.md` ships alongside covering the five API-quality-gate cases (hello world, custom scalar, federation, tenant-scoped `DSLContext`, context arguments from a JWT claim). 649 rewrite unit tests green; 116 execution-tier tests green against the new `Graphitron.buildSchema` wiring.
    - `96e39df` ; Implicit column conditions for `@table` input types: `FieldBuilder.walkInputFieldConditions` carries `enclosingOverride`, `lookupBoundNames`, and a nullable `implicitBodyParams` output; every un-annotated `ColumnField` / `ColumnReferenceField` on a `TableInputArg` that is not `@lookupKey`-bound and not under an override emits a `BodyParam` with `NestedInputField` extraction, folded into the same `GeneratedConditionFilter` as column-bound scalars. `projectFilters` seeds the override flag from parent-field-level and arg-level `@condition(override:true)`; plain inputs pass `null` to keep legacy "explicit-only" semantics. `FieldBuilder.javaTypeFor` drops its `IllegalStateException` guard for `NestedInputField` now that the implicit path produces column-bound body params; `implicitBodyParam` uses `String` for `ID`-typed fields so `DSL.val` coerces at the generated call site. Pipeline: five `GraphitronSchemaBuilderTest` cases (bodyparam emitted, explicit-override-suppresses-own, explicit-suppresses-implicit, lookup-key-skipped, nested-two-level). Execution: five `GraphQLQueryTest` cases (filtersByColumn, nullField, parentFieldOverride, twoFields AND, nested two-level). `PlatformIdField` is intentionally skipped here; the now-shipped `@nodeId` + `@node` directive support replaces it with a synthesized `NodeId` and absorbs the implicit-`@nodeId` case under the same path.
    - Argument-resolution unification, Phase 4 (`9cf83463` + `11dc670a` + `745a2a15`): `@condition` on `INPUT_FIELD_DEFINITION`. `InputField` variants carry `Optional<ArgConditionRef> condition`; `ArgumentRef.TableInputArg` / `PlainInputArg` carry `List<InputField> fields`. `BuildContext.classifyInputField` + `readConditionDirective` host the shared classifier invoked from `TypeBuilder` (type-build time) and `FieldBuilder.classifyPlainInputFields` (per call site). `FieldBuilder.walkInputFieldConditions` walks classified fields and, via `rewrapForNested`, rebuilds each `ConditionFilter`'s `ParamSource.Arg` params against a new `CallSiteExtraction.NestedInputField(outerArgName, path)` variant; `ArgCallEmitter` emits a null-safe `instanceof Map<?, ?>` ternary chain from the top-level arg down to the leaf. Six execution tests cover single-level / override / outer-override / nested / plain / plain-outer-override shapes; `filmsOuterOverrideTableInput` and `filmsOuterOverridePlainInput` are divergence-pins against legacy's "outer owns everything" semantics. Auto-column binding for `@table` input types (63 alf call sites) spun out as its own Active plan; the enclosingOverride accumulator lands with it. Plan promoted to design doc on Done: link:../argument-resolution.md[argument-resolution.md].
    - Per-type `*Wiring` classes (`cadab36` + `2c366bb`): `WiringClassGenerator` at `no.sikt.graphitron.rewrite.generators` emits one `<TypeName>Wiring` class per GraphQL type to `<outputPackage>.rewrite.wiring`, covering five categories (regular, nested with `BatchKeyField` leaves, nested without, Connection, Edge); `ConnectionWiring` / `NestedTypeWiring` are private records inside the generator and the public entry is schema-only (`generate(GraphitronSchema)`). `TypeFetcherGenerator` lost `wiring()`, `emitWiring`, `buildWiringEntry`, `buildPropertyOrRecordFetcherEntry`, `buildWiringMethod`; `GraphitronWiringClassGenerator` shrank to a pure aggregator (`.type(XxxWiring.wiring())` per class name, alphabetically sorted). Lint ratchet `GeneratedSourcesLintTest.wiringAggregatorDoesNotInlineTypeWiring` pins `GraphitronWiring.java` free of any `newTypeWiring(` call so future categories can't quietly re-inline. Follow-up `2c366bb` fixed five raw-type warnings surfaced by the refactor (threading `ParameterizedTypeName` + `WildcardTypeName` through `$T` substitution and broadening two `@SuppressWarnings`) and added two `[Backlog]` Cleanup items (PageInfo wiring decision, `TypeResolver` wiring for interface/union).
    - `89dfea8` ; `DSLContext` params on `@service` methods: `ServiceCatalog.reflectServiceMethod` classifies `org.jooq.DSLContext` parameters as `ParamSource.DslContext`; four `ServiceCatalogTest` cases + one `GraphitronSchemaBuilderTest` pipeline case. `reflectTableMethod` intentionally unchanged ; tracked as backlog.
    - `3357928` ; Sealed-switch dispatch: `TypeFetcherGenerator.generateTypeSpec` exhaustive over all `GraphitronField` leaves; stubbed leaves via `NOT_IMPLEMENTED_REASONS`.
    - `15f9f61e` ; Variant-coverage Phase 1: `IMPLEMENTED_LEAVES` / `NOT_DISPATCHED_LEAVES` partition invariant enforced by `GeneratorCoverageTest`.
    - `1e48c4ee` ; Argument-resolution Phase 1: VALUES + JOIN lookup emission for `QueryLookupTableField`.
    - G5 ; Inline `TableField` emission: `TypeClassGenerator.$fields` via `DSL.multiset`; seven execution tests.
    - `aaadb78b` ; Argument-resolution Phase 2a: inline `LookupTableField` via `InlineLookupTableFieldEmitter`; six execution tests.
    - `7417f53` ; Body-substring test rewrite: `TypeSpecAssertions` helper; 28 → 3 intentionally-marked body-assertion sites.
    - `34359b4` ; Argument-resolution Phase 2b: rows-method bodies for `SplitTableField` + `SplitLookupTableField`; exact JDBC round-trip counts asserted.
    - Record-fields Phase 1: `ResultType` parents; `PropertyField`, `RecordField`, `ConstructorField`, `RecordTableField` with execution tests.
    - Record-fields Phase 2: `RecordLookupTableField` via `deriveBatchKeyForResultType`; five execution tests.
    - `9ba498bc` + `7cf568f4` ; Stubbed-variant validator: `validateVariantIsImplemented` reads `NOT_IMPLEMENTED_REASONS`; build fails on rewrite validation errors by default.
    - `@table` + `@record` input-type fix: `@record` dominates on input types; introduces `BuildContext.warnings()` channel.
    - `d33ace9` ; Variant-coverage Phase 2: `ClassificationCase` interface; 26 enums retrofitted with `variants()` sets.
    - Java-17 output ratchet: `graphitron-rewrite-test` compile goal pinned to `release=17`.
    - Consolidate rewrite modules under `graphitron-rewrite/` shipped at `0e5eb86`.
    - `0b2e4e9` + `49d7879` ; Nesting-field emission: `ChildField.NestingField` out of stubs; eight execution tests.
    - `1abc31ed` + `0c449fef` + `a3afd651` ; Implicit `@reference` path inference: `BuildContext.parsePath` synthesizes single-hop `FkJoin` from the jOOQ catalog when `@reference` is absent; deletes four `SplitRowsMethodEmitter` EMPTY_PATH stub branches and the duplicate FK-count logic in `GraphitronSchemaValidator`.
    - `2530b93` + `f8df839` + `a063d3e` + `ef89bfb` + `1900453` ; Generated-fetcher quality pass: `ConnectionHelper.pageRequest` + emitted `PageRequest` carrier own the full pagination dance (first/last guard, backward/pageSize/cursor derivation, cursor decode, reverse ordering, selection ∪ extraFields name-dedup), with `reverseOrderBy` lifted from per-`*Fetchers`-class to one shared copy; `QueryConditionsGenerator` extracts env-aware condition orchestration into a parallel generated class so entity `*Conditions` stay pure; `$T` substitution replaces every `var`-emitting site in the generator; table-local rename from `table` → `<entity>Table` with `srcAlias` threaded through `ArgCallEmitter` + all `buildCallArgs` callers, breaking the mapper/table name collision; `FieldWrapper.DEFAULT_PAGE_SIZE` unifies four fallback sites; `seekFields: Field<?>[]` matches `decodeCursor`'s declared return type; `ConnectionResult` gains a 2-arg delegating constructor. Three emitted-source lint ratchets (`GeneratedSourcesLintTest`): no `var`, no full-package jOOQ qualification in fetcher bodies, no `graphql.*` imports in entity `*Conditions`. (xref:plans/plan-generated-fetcher-quality.adoc[plan-generated-fetcher-quality.md])
    - `78e3b7c` + `1dce680` ; `SplitTableField` / `SplitLookupTableField` under `NestingField`: `GraphitronSchemaValidator.NESTED_WIREABLE_LEAVES` accepts both `BatchKeyField` variants; `TypeFetcherGenerator.generate` walks `NestingField` descendants of each `TableBackedType` root and emits a narrow `<NestedTypeName>Fetchers` class (`emitWiring=false`) for every nested type with at least one `BatchKeyField` leaf ; plain-object nesting types are absent from `schema.types()`, so the walk is a second pipeline rather than an extended filter. `GraphitronWiringClassGenerator` threads the class name via `ClassName.get(fetchersPackage, …)` so `$L::$L` emits a proper import; `GraphQLRewriteGenerator` filters `fetcherClassNames` to TypeSpecs that carry a `wiring()` method so the top-level builder doesn't invoke a missing method on nested Fetchers classes. `TypeClassGenerator.collectBatchKeyColumns` recurses into `NestingField.nestedFields()` so nested Split BatchKey columns land in the outer parent's SELECT. Coverage: `GraphitronSchemaBuilderTest` classifier case, `SplitTableFieldPipelineTest` + `NestingFieldPipelineTest` structural tests (`TypeSpecAssertions.appendsRequiredColumn` pins the outer-parent PK projection), 2 execution tests in `GraphQLQueryTest` for `Film.info.cast` and `Film.info.castByKey` each batching two parents into one round-trip. Closes the 12-count production rejection.
    - `86ff568` + `3246fd7` + `75e6340` ; Single-cardinality `@splitQuery` support: `FieldBuilder.deriveSplitQueryBatchKey` picks FK-column `BatchKey` for single cardinality / parent-PK `BatchKey` for list (cardinality is the direction signal); classifier rejects `@splitQuery @lookupKey` at single and multi-hop single at classifier time; `SplitRowsMethodEmitter.buildSingleMethod` emits a flat terminal-JOIN returning `List<Record>` with a `scatterSingleByIdx` scatter; `TypeClassGenerator.$fields` always appends each Split* child's BatchKey columns (deduped at runtime); `TypeFetcherGenerator` threads a null-FK short-circuit (single-cardinality fetchers extract the FK to a typed local and return `CompletableFuture.completedFuture(null)` before DataLoader dispatch); scatter-helper emission gated so `scatterByIdx` / `scatterSingleByIdx` are emitted only when the class actually uses them. `JoinStep.FkJoin` docstring corrected to describe `sourceTable` as the traversal-origin table. Coverage: 4 new `GraphitronSchemaBuilderTest` cases (positive + negative §1b / §1c), `ScatterSingleByIdxTest` (reflective unit), 3 pipeline tests in `SplitTableFieldPipelineTest`, 5 execution tests in `GraphQLQueryTest` covering shared-FK dedup (2 round-trips for 5 customers), null-FK short-circuit, non-null-FK resolution, and scatter alignment across mixed-null batches. Closes the 280-count production rejection. (xref:plans/plan-single-cardinality-split-query.adoc[plan-single-cardinality-split-query.md])
    - R15 ; `f65ad06` ; Doc-drift sweep: rewrite-internal docs (`code-generation-triggers.adoc`, `rewrite-design-principles.adoc`, `argument-resolution.adoc`) realigned with `model/` taxonomy. Generators table restructured into four families (fetcher / schema / error-handling / runtime helpers); `QueryEntityField` retired with `EntityFetcherDispatch` footnote; `QueryNodesField`, `ChildField.ParticipantColumnReferenceField`, `ChildField.ErrorsField`, the five `GraphitronType` permits, the `CallSiteExtraction` two sealed sub-groupers, and the `BatchKey` two-axis enumeration all surfaced; `GraphitronSchema` schematic corrected to all five fields; `BatchKey.java` Javadoc updated to "Ten permits across two axis sub-hierarchies". Single R86 forward-ref note added for the typed-rejection / sealed-hierarchies / wire-format-boundary principles slated to consolidate into the public architecture chapter.
    - `3821842` + `62b51c3` + `76887cf` + `c40afb4` ; Lift `@asConnection` rejection on `@splitQuery` fields: `SplitRowsMethodEmitter.buildConnectionMethod` emits the `ROW_NUMBER() OVER (PARTITION BY fk ORDER BY …)` envelope over a `parentInput` VALUES + FK-chain aliased subquery, filtered on outer `__rn__` range, so per-parent Relay pagination works inside DataLoader batches; §2 lifts the fixed-ordering restriction by parameterizing `TypeFetcherGenerator.buildOrderByHelperMethod` on the aliased `Table` so root (`filmTable`) and Split (`a1`) call sites share one helper shape; helper-emission gate adds `SplitTableField+Connection+Argument` alongside the root-field case; classifier permanently rejects `@asConnection` + `@lookupKey` at `FieldBuilder.java:252-257` / `:266-271` (composite lookup keys disambiguate batches, but cursor pagination requires lockstep batches). `ConnectionResult` storage narrowed from `Result<Record>` to `List<Record>`. Coverage: classifier/pipeline/execution tiers all green (545 rewrite + 94 test-spec). Closes the 68-count production rejection.
  • R87 (4867dc0 + d747d93 + b2fcdea + 0ce61c8): @service directives now classify instance methods on (DSLContext) holders, restoring legacy parity. The static/instance fork lives on MethodRef.CallShape; emitter dispatches via serviceCallTarget. MethodRef is sealed with permits NonCondition (which permits Service/StaticOnly) and ConditionFilter. reflectTableMethod carries a positive Modifier.isStatic rejection paired with service-catalog-tablemethod-must-be-static @LoadBearingClassifierCheck. Out-of-band: ServiceHolderFactory extension point not added ; see runtime-extension-points.adoc.

  • R5 (0480a6bd): cleanup-and-hardening pass on the already-shipped composite-key @lookupKey path. LookupMapping.MapInput / DecodedRecord canonical constructors reject empty bindings; three new @LoadBearingClassifierCheck keys (lookup-mapping-bindings-table-coherent, lookup-key-input-field-non-list, lookup-field-non-empty-args) cover the lookup pipeline with matching @DependsOnClassifierCheck consumers on LookupValuesJoinEmitter. New LookupMappingTest pins the type-level invariants; LookupTableFieldPipelineTest extended to assert projected ColumnMapping.MapInput shape; new CompositeKeyLookupQueryTest execution-tier asserts the rendered SQL uses using ("film_id", "actor_id") so single-column regressions surface in test rather than at runtime. The shipped shape is TableInputArg + MapInput, not the ArgumentRef.CompositeLookupArg the original Backlog one-liner anticipated; the unified path was preferable and is locked in by the type-level invariants.

  • R38 (97201f5ee93207 + 5d82380 + 0839488 + 528fc91 + 50195c7): Reshape BatchKey into SourceKey + unify the rows-method seam. Ten BatchKey permits collapse to SourceKey (flat record carrying target, columns, path, wrap, cardinality, reader) + LoaderRegistration (container × dispatch); the rows-method seam routes through one entry point per concern: RowsMethodSkeleton.build (declaration scaffolding + body framing), RowsMethodCall.batchLoaderLambda (BatchLoader lambda), DataLoaderFetcherEmitter.build (DataFetcher dance). Three source-key.* @LoadBearingClassifierCheck keys (SourceRowsCall⇒Row, AccessorCall⇒Record, ServiceTableRecord target-aligned⇒empty path) paired with consumers in GeneratorUtils + SplitRowsMethodEmitter. UnifiedEmissionPinsTest pins the three-fetcher / four-rows-method routing structurally. Net type-identity count: from 10+ permits down to 1 SourceKey + 5 Reader sub-permits + 1 LoaderRegistration. The "Sealed hierarchies over enums" worked example lifted out of the principles doc to a new sibling page graphitron-rewrite/docs/dispatch-axes.adoc. R75’s ResultRowWalk Reader permit will land as a one-permit addition on this foundation.

  • R18 (21c5e57 + c699979f228556 + 2a86e5e + a76383b2a7b3ba + 035ef2b + ed5ebf3 + 39ca34f + a672c82 + bec04f8 + 50dbcdc + 232f8e0 + 0bbd6f3 + 9f41cdc + 5c9109d): Java LSP rewrite + dev goal. Replaces the Rust graphitron-lsp and the legacy graphitron-maven-plugin:introspect JSON producer with a Java LSP module under graphitron-rewrite/graphitron-lsp, served by the single mvn graphitron:dev goal binding 127.0.0.1:8487. Phases 0–6 delivered: lsp4j scaffold (Phase 0), dev-goal binding + watchers + UTF-8↔UTF-16 position conversion (Phase 1), in-process GraphQLRewriteGenerator.buildCatalog() returning tables / columns / FKs / scalars (Phase 2), per-directive completion + diagnostics + Markdown hover for @field / @reference (Phase 3), goto-definition into the jOOQ-generated source tree (Phase 4), @service / @condition / @record autocomplete + hover + diagnostics off a JDK 25 java.lang.classfile-driven scan including the Phase 5d directive-shape correction (descend through the outer service: / condition: / record: arg into the nested ExternalCodeReference) and Phase 5e multi-module reactor visibility (MavenSession.getAllProjects() → every reactor’s compile-output directory; RewriteContext.classpathRoots field + six-arg back-compat overload), and -parameters-missing detection via Parameter.name == null (Phase 5a–5e), and the bonede→jtreesitter binding swap with vendored tree-sitter-graphql grammar source plus per-platform native build (Phase 6). Phase 7 (Rust archival, IntrospectMojo deletion, consumer migration docs) carved out into R91; Javadoc surfacing + per-line definitions + @externalField walk + argMapping autocomplete deferred into R90; multi-platform native CI tracked under R89. 90+ LSP + 48 graphitron-maven module tests green; full reactor build green including graphitron-sakila-example against the new instance-@service fixture from R87.

  • R134 (36122dc + 7fadbda): Fix mutation empty-input short-circuit to use newRecord for single-record payloads. TypeFetcherGenerator.buildMutationDmlRecordFetcher branches the empty-list arm on dataIsList: DSL.using(…​).newResult(<pkProjection>) for the projected-list arm, DSL.using(…​).newRecord(<pkProjection>) for the single-record arm (mutations whose direct return is a single payload, e.g. opprettX(input: [XInput]): XPayload!). The non-empty branch was already gated on dataIsList via fetch()/fetchOne(); this aligns the empty arm. Regression coverage is owned by the compilation tier: graphitron-sakila-example/schema.graphqls declares createFilmsPayload(in: [FilmCreateInput!]!): FilmPayload @mutation(typeName: INSERT), the exact bulk-input + single-payload shape that triggered the bug. The generated MutationFetchers.createFilmsPayload emits Record1<Integer> payload = DSL.using(dsl.configuration()).newRecord(Tables.FILM.FILM_ID) and is compiled against real jOOQ classes; a regression to newResult(…​) would re-emit Result<Record> into a Record1<Integer> local and fail compilation. Scoped to INSERT because bulk UPDATE/UPSERT on MutationDmlRecordField still throw upstream. Follow-up worth filing separately: the non-empty branch on bulk-input + single-record-payload calls .fetchOne() against multi-row valuesOfRows(…​) VALUES, discarding N-1 returned keys at runtime; the compile bug is fixed but the runtime coherence question (validator-side rejection vs. emit lift to .fetch()) deserves its own item.