This is the rendered roadmap. Plans are authored as markdown in graphitron-rewrite/roadmap/; this view derives from the per-item front-matter and the plan bodies. For the model taxonomy, see Code Generation Triggers. For design principles, see Rewrite Design Principles. For workflow conventions, see Workflow. For per-leaf classifier coverage, see Inference-axis coverage report. Or jump to the by-theme view or the changelog. Back to home.

Active

ID Item Status Updated Plan

R19

Rebase and squash rewrite branch onto main

Ready

plan

R222

Dimensional model pivot: slots over cross-product permits

Spec

2026-05-25
created 2026-05-21

plan

R238

ServiceMethodCall walker carrier (R222 foundation slice)

Ready

2026-05-27
created 2026-05-25

plan

R246

UpdateRows walker carrier (R222 UPDATE slice) with PK-or-UK identification
blocked by: methodcall-walker-carrier, simplify-update-mutations-drop-value

Spec

2026-05-27

plan

R45

Multi-tenant routing on top of the schema-driven ExecutionInput factory

Spec

2026-05-20

plan

R23

Multi-parent NestingField sharing: TableField arm

Spec

plan

R186

Nested input types in @mutation fields

Spec

2026-05-20

plan

R13

Faceted search on @asConnection

Spec

plan

R63

Type UPSERT dialect requirement on the model

Spec

plan

R92

Surface database CHECK constraints as Jakarta validation rules

Spec

plan

R8

Docs as an index into classification tests

Ready (deferred)

plan

R26

Retire graphitron-maven-plugin + graphitron-schema-transform

In Progress

plan

R180

Centralize ResultType column-read emission for @record parents

Spec

2026-05-19

plan

R242

DML payload positional input/output alignment

Spec

2026-05-26

plan

R115

Enumerate the capabilities graphitron delivers

Spec

plan

R244

ErrorChannel walker carrier (R222 Stage 2 slice on @service + @tableMethod)

Spec

2026-05-26

plan

R254

Generated GraphitronSchema emission must have bounded chain depth

Spec

2026-05-27

plan

R109

How-to recipe and Sakila fixture for grouped collections via Field<Result<R>> @externalField + multiset

Spec

plan

R212

IntelliJ plugin wrapping graphitron:dev LSP

Spec

2026-05-21

plan

R112

Operation-driven test corpus, capability catalog, and runtime trace
blocked by: capability-catalog

Spec

plan

R188

Replace @value with PK-default partition on UPDATE/DELETE

Spec

2026-05-27
created 2026-05-20

plan

R247

Emit assembled schema.graphqls into generated-resources/graphitron, federation-aware

In Progress

2026-05-27

plan

R250

Propagate schema-applied directives (@link) from consumer SDL to generated buildSchema

In Progress

2026-05-27

plan

R243

Per-field direction in @order/@defaultOrder via FieldSort.direction

In Review

2026-05-27
created 2026-05-26

plan

Backlog

Architecture

  • R234 Support jOOQ embedded and UDT records as non-table input backings: R222 collapsed the legacy JooqRecordInputType arm by rejecting any non-TableRecord jOOQ Record subclass at classification with Rejection.AuthorError("backing class %s is a jOOQ Record but not a TableRecord; supported non-table backings are Java record or POJO"). That stance is correct for today’s graphitron-fixtures-codegen and Sakila surfaces (no real schema currently binds a non-table jOOQ Record as an input), but it bakes in a rejection that will trip the moment a consumer wants to use a jOOQ embeddable record (jOOQ 3.20+ feature for grouping related columns into a typed structure) or a UDT record (PostgreSQL composite type) as an input. Both are legitimate jOOQ-side carriers; both have stable Java accessors but no TableRef of their own. This item reintroduces the backing-class arm(s) those cases need — likely EmbeddableRecord(fqClassName, embeddable: EmbeddableRef) and UDTRecord(fqClassName, udt: UDTRef) rather than a generic JooqRecord catch-all, so each arm carries the structural metadata its downstream consumers actually want. Scope: extend BackingClass with the new arm(s), wire the visitor’s runtime-shape classification to detect them, decide whether they participate in classifiedFields (UDT-typed inputs may project onto a single column whose value is the UDT instance, which is a different code path than table-bound inputs), and add fixtures. Out of scope for R222 because no consumer needs it yet; lands once a real fixture or user case surfaces. (updated 2026-05-23)

  • R171 Fold InputType and TableInputType under sealed parent InputLikeType: GraphitronType today permits InputType (with four leaves: PojoInputType, JavaRecordInputType, JooqRecordInputType, JooqTableRecordInputType) and TableInputType as siblings. Any capability uniformly true of "things that come in as SDL input" (R94’s HasInputRecordShape, R98’s ConstraintSet-attachment slot, any future input-side carrier) must be declared on five places instead of one, and a sixth input-like variant added to GraphitronType.permits will not get a compile-time miss for the capability. Fold the two siblings under a sealed interface InputLikeType extends GraphitronType permits InputType, TableInputType, and relocate input-side capabilities onto that root. Cleanup item surfaced by R94’s capability-declaration site; deferred from R94 to keep that item narrow. Not blocking: capability interfaces declared on five sites work today, the fold tightens the sealed root so the compiler enforces the invariant. (updated 2026-05-17)

  • R103 Lift jOOQ column defaults onto input fields connected to that column: When a GraphQL input field is wired (via @field(name:) or implicit name match) to a jOOQ-generated column whose DataType carries a defaulted() expression, surface that default in the schema so clients can see it and so omitted values get a typed, server-known default rather than silently relying on the database. The current generator path already emits DSL.defaultValue(dataType) when an input key is absent at insert/update time (TypeFetcherGenerator.java:1456, :1496, :1508, :1769), so the runtime story is correct — the gap is purely on the contract side: the SDL says nothing about which input fields have a database-supplied default, and clients that introspect the schema have to read the migrations to find out.

  • R231 Emit text-mapped-enum fields as the GraphQL enum type, not String: When an SDL field is declared with an enum return type whose values use @field(name:) to bind to a varchar column (e.g. textRating: TextRating with enum TextRating { PG_13 @field(name: "PG-13") …​ }), graphitron’s field-emit lowers the GraphQL field type to String in the generated FieldDefinition (see FilmType.java:36 for textRating). The fetcher returns the raw column string, and graphql-java’s Coercing layer is never engaged for that field — clients see the runtime form ("PG-13") instead of the SDL identifier (PG_13). (updated 2026-05-22)

  • R57 FK-target argument @nodeId, JOIN-with-translation emission: R40 shipped the simple direct-FK case for argument-level FK-target @nodeId: when the FK source columns positionally match the target NodeType’s keyColumns, projectFilters emits BodyParam.In / Eq / RowIn / RowEq against joinPath[0].sourceColumns() directly, no JOIN required.

  • R166 GraphQLSchemaVisitor as the driver for code generation: Backlog stub. Floats an architectural idea that surfaced while diagnosing R165 (fetcher-registration-empty-body-filter). Connected to R164 (field-model-two-axis-pivot); the two reorganise different surfaces of the same pipeline and a clean field model is the natural foundation for a cleaner emission walk. (updated 2026-05-15) (blocked by dimensional-model-pivot)

  • R52 Lift lookup-vs-query operation taxonomy into the model: R50 named the lookup-vs-query split as a documentation-level distinction without lifting it into the model. The distinction is real and structurally consequential: lookups carry a derived VALUES table with an idx column to preserve per-input-row identity, queries fold predicates into a WHERE with no input-row identity to track. Today the split is encoded only by variant identity (LookupMapping vs everything else) and routing decisions taken in individual generators.

  • R72 Slim ServiceCatalog down to a lookup primitive: ServiceCatalog.java (~700 lines) is named like a lookup helper but carries three resolver-sized routines on top of the lookup: reflectServiceMethod (~170 lines), reflectTableMethod (~90 lines), and reflectExternalField (~50 lines). Each owns its own Class.forName + getDeclaredMethods scaffolding, its own argument-binding policy (override targets, typo guards, SOURCES batching hints), its own expected-return-type rule, and its own rejection wording. The caller surface (ServiceDirectiveResolver, TableMethodDirectiveResolver, ExternalFieldDirectiveResolver, ConditionResolver, BuildContext) is the natural home for those policy decisions; the catalog should expose lookup-shaped operations only.

  • R97 Deprecate @table on input types; consumer-derived tables + argMapping grouping: The @table directive on input types declares "this input maps to columns of table X". The classifier consumes it to produce GraphitronType.TableInputType (TypeBuilder.buildTableInputType at TypeBuilder.java:686-718), and downstream MutationInputResolver, EnumMappingResolver.buildLookupBindings, FieldBuilder (line ~697), and GraphitronSchemaValidator.validateTableInputType all switch on that variant. The directive is the structural signal that drives DML emit, @lookupKey resolution, and condition-input column binding.

  • R239 Lift ColumnField.parentTable from emitter parameter to record component: Surfaced by R237 Phase 2 as the (b-cheap) candidate keyed column-field-requires-table-backed-parent. The classifier produces a ChildField.ColumnField only on a table-backed parent, but the parent table itself is currently threaded into TypeFetcherGenerator.generateTypeSpec as a parameter rather than carried on the ColumnField record. The switch arm at TypeFetcherGenerator.java:319 reads parentTable from the parameter and throws IllegalStateException if null, treating a structurally-precluded reachability as a defensive guard. (updated 2026-05-25)

  • R192 Mojo-configured custom Bean Validation factory: Originally drafted as part of R45 (tenant-routing-and-execution-input.md) and inherited by R190 (single-tenant-execution-input-factory.md); carved out because the validator-override mechanism is independent of those items' surface narrowing. The generated GraphitronContext impl’s getValidator(env) returns Validation.buildDefaultValidatorFactory().getValidator(); consumers who need a custom Validator (custom ConstraintValidator implementations, alternative providers, CDI integration) have no seam today and would have to reach for the legacy GraphitronContext interface that R190 seals. The proposed shape is a new Mojo element naming a consumer-supplied factory class whose instance graphitron calls per request: this pushes against R45’s "extension points that don’t pay for the openness" critique one level down, since the override hook is itself a per-request consumer-implemented surface. The Spec author must justify whether the override is per-request (functional interface, (DataFetchingEnvironment) → Validator) or per-build (configured class graphitron instantiates once and calls .getValidator() on), and what the migration story is for consumers currently overriding GraphitronContext.getValidator(env) in the legacy generator. Depends on R190 landing first so the sealed-context method set is the baseline this item widens. (updated 2026-05-20)

  • R46 Multi-tenant fan-out for @service: A custom resolver in a downstream Sikt project (megVedLarested) bypasses @service and writes the resolver by hand because the directive can’t express what it needs: for each tenant the logged-in user belongs to, open a tenant-scoped DSLContext, fan out in parallel on the executor, drop nulls, return the union. The service method itself is GraphQL-free Java; what doesn’t fit @service is the ConnectionManager lookup, the per-tenant DSLContext plumbing, and the executor.allOf().join() shape. (blocked by tenant-routing-and-execution-input)

  • R123 Parent-context-aware schema coordinates for per-directive Behavior policy: R119 keyed the LSP’s directive vocabulary on GraphQL-spec schema coordinates: Directive, DirectiveArg, InputType, InputField. The granularity is correct per the GraphQL spec, but it under-specifies one axis the LSP needs.

  • R66 Widen string-carrier intermediates onto Rejection (R58 follow-up): R58 lifted the direct candidate-hint producers onto typed [Rejection.AuthorError.UnknownName](../graphitron/src/main/java/no/sikt/graphitron/rewrite/model/Rejection.java) factories. Five intermediate carriers still flatten the typed shape into prose before it reaches a Rejection consumer, blocking five candidate-hint producers from reaching the typed surface their factories (unknownForeignKey, unknownTypeName, unknownEnumConstant, unknownNodeIdKeyColumn, unknownColumn) already exist for. R58 Phase D shipped the factories; this plan adds the carrier widenings so the typed values reach consumers.

  • R11 DSLContext on @condition / @tableMethod methods: Lift the reflectTableMethod gate. Requires ArgCallEmitter to walk params() instead of callParams() so the injected DSLContext lands at its declaration-index slot.

  • R71 @batchKeyLifter Record return-type symmetry: [R61 (emit-record1-keys-instead-of-row1.md)](emit-record1-keys-instead-of-row1.md) added RecordN<…​> source-shape support alongside the pre-existing RowN<…​> shape on the @service classifier path: developers freely choose either at the source declaration, and variant identity tracks shapeRowKeyed / MappedRowKeyed carry RowN keys; RecordKeyed / MappedRecordKeyed carry RecordN. The only consumer-supplied surface left without that symmetry is @batchKeyLifter, where BatchKeyLifterDirectiveResolver still pins the lifter method’s return type to org.jooq.Row1..Row22 (BatchKeyLifterDirectiveResolver.java:266-273); a Record1..Record22 return is rejected today. This item brings the lifter API to the same Row-or-Record symmetry the source-shape path already has.

  • R98 Multi-source input validation: SDL directives + DB CHECK + Jakarta on a unified rendered schema: R94 emits an internal Java record per SDL input type. R92 phase 3 attaches programmatic Jakarta ConstraintMapping entries to those records derived from PostgreSQL CHECK constraints. R12 §5’s pre-execution validator step runs against each input at the fetcher boundary. Three pipes today; three different sources of truth for "what does this input need to look like to be valid"; only one of them (DB CHECK) is currently surfaced to consumers anywhere outside of the runtime violation report. (blocked by catalog-check-constraint-validation)

  • R193 Sealed UnresolvedParam classification for @service parameter rejection arms: The diagnostic-arm decision inside ServiceCatalog.reflectServiceMethod (ServiceCatalog.java:258-329, the sourcesShape.isEmpty() block) is a chain of predicates over the unresolved Java parameter: classifySourcesType().isEmpty(), then pName == null, then parentPkColumns.isEmpty() && looksLikeSourcesShape(…​), then dtoSourcesRejectionReason(…​) != null, then the generic "unrecognized sources type" fall-through. Two recent bug items (R185 root, R187 nested) each adjusted the precedence in different directions ; R185 narrows the SOURCES-batch arm so List<XRecord> at root falls through to the arg-mismatch diagnostic; R187 drops the parentPkColumns.isEmpty() gate so the arg-mismatch arm fires at nested coordinates whenever the parameter isn’t SOURCES-adjacent. Both fixes are correct, both ship as small surgical diffs, but the cumulative shape is a fan-out of overlapping predicates with no single record that says which classification the parameter actually fell into. The principles-architect review on R187 flagged this directly: precedence is a property of the classifier, not the diagnostic emitter, and asking it in two places invites future bugs whenever the predicate set grows again. (updated 2026-05-20)

  • R240 Type-token threading on MethodRef.StaticOnly + ReturnTypeRef.TableBoundReturnType: Surfaced by R237 Phase 2 as the (b-relational) candidate keyed service-catalog-strict-tablemethod-return. ServiceCatalog.reflectTableMethod rejects developer methods whose return type is wider than the generated jOOQ table class via a strict ClassName.equals comparison, and TypeFetcherGenerator.buildQueryTableMethodFetcher (generators/TypeFetcherGenerator.java:1035, :1114) declares <SpecificTable> table = Method.x(…​) with no cast and feeds the local directly into <SpecificTable>Type.$fields(…​). The contract is a relationship: the field’s table token equals the method’s return token at runtime, but neither MethodRef.StaticOnly nor ReturnTypeRef.TableBoundReturnType carries this relationship structurally. (updated 2026-05-25)

  • R172 Audit: forbid service-side references to <outputPackage>.inputs.*: R94 emits a graphitron-internal Java class per SDL input type under <outputPackage>.inputs.<InputName>. The class is a Jakarta-validation target: the fetcher boundary calls <InputName>.fromMap(env.getArgument(…​)), hands the result to validator.validate(…​), and discards it. Service code (under the consumer’s package, never under <outputPackage>.inputs) must not reference these classes; doing so re-creates the service-side-graphitron-coupling R150’s design rules out. (updated 2026-05-17)

  • R122 Compound mutations: parent entity row + child normalised rows in one INSERT: A common entity-storage pattern is one parent row in an entity table plus N rows in one or more normalised child tables (typed-attributes, many-to-many association rows, etc.) keyed off the parent’s PK. Today graphitron’s @mutation(typeName: INSERT) admits exactly one DML target table per mutation; consumers wanting "insert one entity + its normalised children" have to author a @service mutation that orchestrates the inserts in Java, even when the relationships are entirely declarative from the SDL/jOOQ catalog perspective. (updated 2026-05-23)

  • R25 Rebalance test pyramid: Shift new test investment from per-variant structural tests toward SDL-to-classification-to-emission pipeline tests keyed off graphitron-fixtures.

  • R174 graphitron-javapoet: emit records, sealed/permits, package-info.java: graphitron-javapoet is forked from Square’s JavaPoet at a point that predates Java records (Java 14+) and sealed types (Java 17+). The emit framework supports four TypeSpec.Kind values: CLASS, INTERFACE, ENUM, ANNOTATION. Records, sealed/permits clauses, and package-info.java files cannot be generated through the framework today. The rewrite uses graphitron-javapoet exclusively for code emit, so any emitter that wants to produce these shapes hits a wall. (updated 2026-05-17)

  • R7 Decompose TypeFetcherGenerator: TypeFetcherGenerator.java is 1 646 lines, one public entry point (generate(GraphitronSchema)), and ~30 private methods that implement per-field-variant emitters plus shared helpers. It is the counterpart to the now-shipped FieldBuilder decomposition (R6, see [changelog.md](changelog.md)): a central generator that has accumulated coverage faster than its file shape can absorb.

  • R145 Cardinality safety story for UPSERT under the multiRow: regime: R144 inverts the cardinality-safety polarity on DELETE and UPDATE (default treats every input field as a WHERE filter; PK coverage required; multiRow: true on @mutation is the opt-out). UPSERT is carved out at R144’s classify-time rejection because its semantics differ: INSERT …​ ON CONFLICT (cols) DO UPDATE SET …​ requires the conflict-target columns to form a unique constraint by definition, and one input row matches at most one existing row. The multiRow: knob does not apply the same way. This item designs the UPSERT-specific safety story, lifts R144’s classify-time rejection, and restores UPSERT-generation. Existing UPSERT fixtures in sakila-example and GraphitronSchemaBuilderTest migrate as part of this work.

  • R146 Unique-index coverage as an alternative to PK for mutation cardinality safety: R144’s PK-coverage check on DELETE / UPDATE filter columns is the conservative cut: PK is the only unique key the validator considers when deciding whether to require @multiRow. Real schemas often have alternate unique constraints (e.g. a natural-key column with a unique index alongside a surrogate PK), and forcing those mutations under @multiRow discards the per-row cardinality guarantee the unique index would otherwise provide. This item generalises the check from "covers PK" to "covers some unique key" by exposing unique-index metadata on JooqCatalog and threading the matched-key identity through to the lookup-WHERE emitter (so RETURNING / follow-up SELECT know which columns to round-trip). Independent from R145’s UPSERT story; either can ship first.

  • R207 Audit design-doc claims for implementation conformance: R205 surfaced a five-layer survival pattern where a documented design claim (docs/argument-resolution.adoc’s truth table at `:262-275 saying plain inputs and @table inputs share the same implicit-predicate behaviour) diverged from the implementation (FieldBuilder.java:1349 passing null for implicitBodyParams on plain inputs) and survived because no enforcing test asserted the symmetry. The same shape — design doc says X, code does Y, no test pinning X — plausibly exists elsewhere in the rewrite-internal docs (argument-resolution.adoc, typed-rejection.adoc, rewrite-design-principles.adoc, per-resolver javadocs). (updated 2026-05-21)

  • R218 Carry inference provenance on ParamSource.Arg so resolved bindings audit cleanly: R214’s ServiceCatalog.inferBindingsByType mutates argByJavaName silently between the override-typo check and the per-parameter loop. The resulting ParamSource.Arg(extraction, path) is structurally identical regardless of whether the binding came from an explicit argMapping, a same-name identity match, the arity-unique inference branch, or the type-unique inference branch. The resolved-coordinate report and any future LSP "where did this binding come from?" surface can’t tell them apart. The principles-architect review (round 1, finding 4) flagged this as a "load-bearing invariant doesn’t have an emit-time witness" gap, citing the typed-rejection / auditable-resolution narrative the project rests on. (updated 2026-05-21)

  • R220 Consolidate looksLikeSourcesShape, couldBeSourcesShape, and classifySourcesType into one predicate: ServiceCatalog now has three closely-related predicates over the same Java parameter shapes, each subtly different: looksLikeSourcesShape (Row<N> / Record<N> lists only, used by the root-coordinate diagnostic), couldBeSourcesShape (R214 addition; adds TableRecord to the above, used by the inference gate to exclude SOURCES-shape params from candidate binding), and classifySourcesType (gated by parentPkColumns.isEmpty() and produces a typed SourcesShape result). The principles-architect review (round 1, finding 5) flagged this as the "same predicate evaluated by multiple consumers" smell — the resolver is under-specified, and the three predicates have already drifted apart in subtle ways. (updated 2026-05-21)

  • R117 Graphitron knowledge base programme: DuckDB as queryable model: This item is a programme, not a single deliverable. It frames the DuckDB store graphitron emits at build time as a queryable model of everything graphitron knows about itself: the SDL it parses, the classifications it produces, the code it generates, the runtime it observes, the documentation it ships, the roadmap that drives it. R104 introduced the store as a coverage scratchpad. R112 extends it with operations, capabilities, and runtime trace. The programme this item defines is the deliberate continuation: keep adding dimensions, keep them naturally keyed, keep the store a projection (rebuilt on every build, never a competing source of truth), and grow toward a knowledge graph queryable end-to-end. The consumer surface is R118 (the graphitron MCP server), but the value lands first inside the build itself: each dimension absorbed makes the next coverage view, doc render, or static check materially cheaper to write.

  • R219 Unify arity-unique and type-unique inference under a single JavaTypeKey-counted rule: R214’s inferBindingsByType shipped with two sibling rules (arity-unique and type-unique) sequenced as a "fallback ladder": arity-unique returns early when applicable; type-unique handles the residual case. The principles-architect review (round 1, finding 2) flagged this as a discontinuity: a working schema (input: SomeInput) → (SomeInput payload) binds via arity-unique today; the moment an SDL author adds a second argument of an unrelated type, the same payload now needs the type-unique branch, which by construction can’t see named-input-object slots (mapToJavaTypeName returns null for them and they’re dropped from slotsByType). The binding silently disappears. The user’s stated rule was "one and only one possible mapping" — a second argument of a different type doesn’t introduce a second mapping for the existing pair, it just adds a sibling. (updated 2026-05-21)

  • R221 Validator walks PlainInputArg.fields() for UnboundField rejection: R215’s validator-side check on InputField.UnboundField (per the spec’s §Validator rules: condition.isPresent() && !condition.get().override() → reject at the directive’s source location) only walks GraphitronType.TableInputType.inputFields() via GraphitronSchemaValidator.validateTableInputType + validateInputFieldRecursive. Truly plain input types (GraphitronType.InputType permits — JavaRecordInputType, PojoInputType, JooqRecordInputType, JooqTableRecordInputType) carry no classified InputField records on the type; their fields classify at consumer time on ArgumentRef.InputTypeArg.PlainInputArg.fields(). The validator’s whole-schema walk has no view into PlainInputArg, so plain-input UnboundField + @condition(override:false) shapes escape the validator-mirrors-classifier rule. R215’s acceptance test #5 (r215_validatorRejectsOverrideFalseOnNonBindingField) was written against a @table input (which routes through the existing walker) so the test passes, but the literal spec phrasing "non-binding plain input field" is structurally unreachable today. (updated 2026-05-21)

Cleanup

  • R133 Flip leaf-coverage profile activation to opt-in: The leaf-coverage profile in graphitron-rewrite/pom.xml is activated by negation (<name>!leaf-coverage.skip</name>), so every default contributor mvn verify truncates target/leaf-coverage.jsonl in process-test-resources and threads a graphitron.classification.trace system property into every surefire/failsafe. The traces are only consumed by roadmap-tool leaf-coverage, which after R132 runs only in the CI regeneration step. Every other build pays the antrun-truncate cost and writes JSONL nobody reads.

  • R54 Rename @externalField (parallel-support, deprecation, migration): @externalField lifted to IMPLEMENTED_LEAVES end-to-end in computed-field-with-reference (R48, shipped; see [changelog.md](changelog.md)). The directive’s name is the surviving historical artefact: it predates the ChildField.ComputedField model variant and reads as "field resolved by external code" rather than the narrower behaviour the lift settled on (a Field<X> returned by a static method, inlined into the SELECT projection at the alias). A clearer name ships in this plan; the old name stays accepted for one consumer-migration window.

  • R27 Retire @nodeId and IdReferenceField synthesis shims: Two parallel shims survive in the classifier so legacy SDL keeps building. Both should retire on the same gate (sis migration to canonical SDL); their wire shape is independent but the user-visible migration is one piece of work, so the two retirements ship together. (blocked by sis-rewrite-migration)

  • R51 Split PropertyField/RecordField on parent-kind instead of nullable column: ChildField.PropertyField and ChildField.RecordField each carry both columnName: String and column: ColumnRef, with column nullable depending on the parent type: non-null when the parent is a JooqTableRecordType with a resolvable column, null for JooqRecordType / JavaRecordType / PojoResultType parents. The single record straddles two parent kinds via an Optional component, leaving columnName as the only carrier of the SDL string when column is absent. Per Narrow component types over broad interfaces and Sub-taxonomies for resolution outcomes, the right shape is two sealed-arm variants (one for table-backed parents carrying a non-null ColumnRef, one for non-table-backed parents carrying just the SDL string), not one record with a nullable component. Split surfaced during R50’s columnName cleanup on ChildField.ColumnField / ColumnReferenceField, where the table-backed-only invariant let those carriers retire columnName outright; this item carries the same rigour to PropertyField and RecordField.

  • R16 FkJoin model cleanup: JoinConditionRef wrapper: FkJoin.whereFilter and ConditionJoin’s condition method are typed `MethodRef today, but they’re not arbitrary method references: the generator calls them with a fixed (srcAlias, tgtAlias) two-argument convention via JoinPathEmitter.emitTwoArgMethodCall. The same MethodRef interface is also implemented by ConditionFilter, which carries the separate WhereFilter calling convention. The two shapes share a type today; conflating them has been a recurring source of confusion (the field name whereFilter on FkJoin is itself a misnomer, since it’s a join-condition, not a filter).

  • R235 Tidy @reference path-element surface: separate join-shape from WHERE-filter: The legacy ReferenceElement { table, key, condition } directive surface combines three roles in one input object: key: and table: and condition: (without companions) name the join shape; condition: combined with key: or table: names a WHERE-filter that folds onto the FkJoin’s whereFilter. The full combinations table at R232’s spec lines 44-52 documents seven valid shapes. The conflation invites cargo-culting (condition: sometimes means "ConditionJoin", sometimes "WHERE filter on FkJoin") and the seven-shape free combination invites authoring drift. (updated 2026-05-23)

  • R17 Annotated walkthrough of a generated file: Today’s docs cover the input side (schema → classification → variant) and the model side (sealed hierarchy, capability interfaces, design principles) but a contributor reading them never sees a complete generated file explained section by section. The mental model "this is what the output looks like" gets reconstructed from grepping graphitron-test/target/.

  • R76 Emit per-participant fieldsJoin and orderBy; replace SelectJoinStep mutation in interface fetchers: TypeFetcherGenerator.buildQueryTableInterfaceFieldFetcher and buildTableInterfaceFieldFetcher emit dynamic jOOQ queries by declaring a SelectJoinStep<Record> step local and reassigning it inside if (alias != null) blocks (buildCrossTableJoinChain, see [TypeFetcherGenerator.java:686-700](../graphitron/src/main/java/no/sikt/graphitron/rewrite/generators/TypeFetcherGenerator.java) and [TypeFetcherGenerator.java:759-770](../graphitron/src/main/java/no/sikt/graphitron/rewrite/generators/TypeFetcherGenerator.java)). That is not the idiomatic jOOQ pattern for dynamic joins; jOOQ’s documented form folds the conditional join into a single fluent expression via DSL.noTable() / DSL.noCondition(), which jOOQ erases at render time. The step-mutation form also centralises join construction in QueryFetchers, breaking symmetry with the existing per-type-class $fields(…​) helper (which already gates SELECT entries by selection set on the participant type).

  • R47 Short class-name resolution for @service and @externalField (legacy parity): ServiceCatalog.reflectServiceMethod currently calls Class.forName(className) directly, forcing an FQN. Existing schemas carry short class names like className: "PersonService" and rely on the legacy Mojo’s externalReferenceImports list to find them. Without short-name resolution, every legacy schema has to be migrated to FQNs at the same time as it migrates to the rewrite, which is unnecessary friction.

  • R35 Class-level Javadoc and package-info.java sweep: A reader landing on FieldBuilder.java (2 172 lines) or TypeFetcherGenerator.java (1 646 lines) gets minimal in-file orientation; they have to bounce to the docs to learn what the class is for. The rewrite tree also has zero package-info.java files, which is the IDE-native place for "what is in this package" blurbs.

  • R116 Cover composite-key Row2 path-keyed @sourceRow classification: R110 shipped @sourceRow with Row2..Row22 arity admitted by the resolver: the per-position type loop in SourceRowDirectiveResolver iterates the lifter’s RowN type arguments without special-casing arity 1, and the leaf-PK arm constructs LifterLeafKeyed over whatever the leaf’s TableRef.primaryKeyColumns() returns. The existing SourceRowClassificationCase test enum exercises Row2 only on the rejection path (LEAF_PK_ARITY_MISMATCH against inventory.inventory_id); no successful Row2 path-keyed classification fires anywhere in the test corpus today. The gap is in the test catalog (no 2-column FK exists in graphitron-rewrite/graphitron/src/test/…​), not in resolver / emitter code.

  • R120 Drop or wire FkJoin.alias dead storage: BuildContext.synthesizeFkJoin (BuildContext.java:694) populates FkJoin.alias as fieldName + "_" + stepIndex (e.g. "language_0") while resolving a @reference path. No code reads it: JoinPathEmitter.generateAliases (JoinPathEmitter.java:41) derives its own per-hop aliases from the target table’s javaClassName() + hop index, and emitters layer their own runtime prefixes for self-ref recursion uniqueness on top. The stored value is never consulted. Same applies to the sibling ConditionJoin.alias slot.

  • R126 Scrub residual BatchKey.X references from sakila-service / sakila-example prose: R38’s Phase 3 follow-up ("Stale-prose scrub", commit 5d82380) claimed 118 BatchKey references in Javadoc / comment prose were cleaned up, but several sites in the sakila-service test fixtures, sakila-example execution test, and the example schema.graphqls description comments still mention deleted BatchKey.X permits (some as dead {@link no.sikt.graphitron.rewrite.model.BatchKey.X} Javadoc, some as plain prose). Build is green (Javadoc lookups are best-effort), but the references are noise for readers and trip code search.

  • R64 buildRuntimeStub accepts Rejection.Deferred directly: SplitRowsMethodEmitter.unsupportedReason returns Optional<Rejection.Deferred> (per R58 Phase C, commit 68a062c). Each of the four buildFor* callers immediately calls .message() on the result to feed the String reason parameter of buildRuntimeStub, discarding the typed EmitBlockReason the sealed Deferred arm carries:

  • R10 Drop the assembled-schema rebuild in favour of per-variant graphql-java forms: Phase 5 of [firstclass-connection-types](firstclass-connection-types.md) rebuilds the assembled GraphQLSchema via SchemaTransformer so directive-driven @asConnection carriers carry their rewritten return type and pagination args. The rebuild only runs at generate time and is never seen by the runtime (which reconstructs its schema from emitted <TypeName>Type.type() calls in GraphitronSchema.build()).

  • R24 NodeIdReferenceField JOIN-projection form: R50 shipped two of the three rooted shapes named in Variant-by-variant collapse → Single-hop emission, two shapes: rooted-at-child emission (FK-mirror, no JOIN, parent’s FK columns encode directly) and the classifier-side resolution for rooted-at-parent (phase g-B produces ChildField.ColumnReferenceField / CompositeColumnReferenceField with compaction = NodeIdEncodeKeys and a resolved joinPath). What did not ship is the matching emitter: FetcherEmitter#dataFetcherValue carries runtime UnsupportedOperationException stubs for both arms (lines 140-162), so a schema that reaches one of the rooted-at-parent shapes builds without a validator-side rejection but throws at runtime.

  • R34 sis-graphql-spec migration to graphitron-rewrite: Track the consumer-side schema work needed to bring sis-graphql-spec cleanly onto graphitron-rewrite. This plan exists because sis is the canonical large-scale consumer; closing it out validates the rewrite’s classification contracts end-to-end and lets us close courtesy windows on shims (notably [retire-synthesis-shims](retire-synthesis-shims.md), which gates on this work).

  • R168 Sub-agent classifier for blast-radius effort (Low/Medium/High) at Spec stage: The roadmap roll-up sorts by priority: but carries no signal about how big a piece of work each Active item is. A reader scanning the table cannot tell whether Ready means "one afternoon" or "a multi-phase lift across four modules", and the author setting priority: is making that judgement implicitly without surfacing it. Add an effort: front-matter field with values Low | Medium | High defined as blast radius (files touched, design forks, test tiers reached), populated by a sub-agent classifier at the Spec stage (Backlog stubs are too thin to grade against and explicitly do not carry the field). The classifier reads one plan file at a time and emits a single bucket, so a batch reclassify across ~30 Active items costs roughly one normal turn’s worth of tokens. Render the field as a column on the Active table in roadmap/README.md and as an attribute in the per-plan AsciiDoc page; gate the validator so an effort: value on a Backlog item is a hard error. (updated 2026-05-16)

  • R85 Emit graphitronContext helper into Conditions and Type classes: @condition(contextArguments: […​]) is a documented feature (docs/getting-started.adoc:198,226, runtime-extension-points.adoc:96-101) but its generated output does not compile. The classifier produces CallSiteExtraction.ContextArg for context-bound filter parameters (MethodRef.java:111, exercised at GraphitronSchemaBuilderTest.java:2768); ArgCallEmitter.buildArgExtraction’s `ContextArg arm emits graphitronContext(env).getContextArgument(env, …​); the call lands in <RootType>Conditions.<field>Condition() (via QueryConditionsGenerator) or in <TypeName>.$fields() (via InlineTableFieldEmitter / InlineLookupTableFieldEmitter). Neither host class emits a graphitronContext helper, so the generated source fails at mvn compile -pl :graphitron-sakila-example with "cannot find symbol: graphitronContext". The bug is currently latent because no fixture in graphitron-sakila-example or graphitron-fixtures-codegen uses contextArguments on @condition.

  • R208 Retire the @asConnection(connectionName:) deprecated argument: @asConnection(connectionName:) is deprecated in directives.graphqls (the SDL @deprecated marker landed alongside R93’s SdlAction migration registry; see directives.graphqls:243) but still functional: ConnectionPromoter.resolveConnectionName honours an explicit override when present and falls back to the <ParentType><FieldName>Connection derivation otherwise. The deprecation reason states the architectural concern: sharing one synthesised type across distinct carrier fields conflates parents / filters / orders at the type level, and the override exists "only as a transition mechanism for legacy schemas". (updated 2026-05-21)

  • R245 Wire @condition through to mutation WHERE (emit half + new placements): @condition on mutations is half-built today: MutationInputResolver.java admits input-field-level @condition(override: true) (R215, lines ~482-498) but the directive is a no-op at emit (no .where(…​) clause is produced). Argument-level @condition on a non-@table mutation argument is rejected outright (line 446). Input-field-level @condition without override: is rejected. This item closes the emit half and lifts the two admission rejections so the directive does something useful. (updated 2026-05-27) (blocked by simplify-update-mutations-drop-value)

  • R227 mdBodyToAdoc: translate markdown tables to AsciiDoc when rendering roadmap: Main.mdBodyToAdoc (graphitron-rewrite/roadmap-tool/src/main/java/no/sikt/graphitron/roadmap/Main.java) is a best-effort markdown→AsciiDoc converter used by render-adoc to stage roadmap plan bodies into the documentation site. It currently passes markdown tables (| col | col | header rows separated by a |---|---| line) through verbatim, so the rendered .adoc carries raw pipe rows that Asciidoctor displays as paragraph text with literal pipes rather than a table. R223 added a verify-phase check that flags this pattern in authored .adoc, but explicitly defers the render-side hole; markdown tables embedded in .md roadmap plans still leak into the rendered roadmap site. This item is the follow-up: extend mdBodyToAdoc to detect a markdown-table block (header row + separator + body rows, terminated by a blank line) and emit an AsciiDoc |=== block with a [cols=…​] attribute synthesized from the separator’s column count and any GFM alignment markers. (updated 2026-05-22)

Validation

  • R136 Execution-tier coverage for FK-target/NodeType-keyColumns permutation: R131’s permutation relaxation is pinned at the pipeline tier (InputFieldFkTargetNodeIdCase.FK_TARGET_REORDERED_KEY_PERMUTATION_DIRECT_FK{,_SINGULAR} in NodeIdPipelineTest), which asserts liftedSourceColumns is permuted into @node.keyColumns order on the resolver’s DirectFk carrier. The end-to-end SQL correctness — that the emitted BodyParam.RowEq against liftedSourceColumns actually matches the right rows when joined against decoded NodeId values — is not exercised by an execution-tier test in this repo.

  • R135 Multi-hop @nodeId pipeline test for FK-target/NodeType-keyColumns permutation: R131’s permutation relaxation in NodeIdLeafResolver.resolve accepts set-equality between the terminal hop’s target columns and the NodeType’s @node(keyColumns:), then permutes liftedSourceColumns into NodeType-keyColumns order before constructing Resolved.FkTarget.DirectFk. The pipeline-tier test pinning this lands on the single-hop reordered_pk_parent fixture (InputFieldFkTargetNodeIdCase.FK_TARGET_REORDERED_KEY_PERMUTATION_DIRECT_FK{,_SINGULAR}).

  • R181 Validate @order/@defaultOrder: empty directive and @index coexistence: A real user report (paraphrased) crashed the schema build: (updated 2026-05-20, created 2026-05-19)

  • R107 Classify leaf mentions in inference-axis-coverage report: LeafCoverageReport.parseMentions (R104) joins each sealed leaf simple-name against every roadmap *.md body via a \b<simpleName>\b regex. The match is undifferentiated: backticked code spans, code-fenced blocks, and bare prose mentions all collapse into the same Roadmap cell. Two consequences. First, every roadmap edit that names a leaf in any form drifts inference-axis-coverage.adoc and trips the verify-leaf-coverage-report CI gate, which is the regen-friction tax R104 deferred. Second, a reviewer reading the column has no way to sanity-check a match — Field against FieldType is excluded by \b, but a phrase like "the field type" cannot be told apart from a deliberate Field symbol reference without re-reading the source spec body.

Other

  • R148 Advance SourceLocation past description so diagnostics point at the field, not the doc block: graphql-java’s FieldDefinition.getSourceLocation() (and the same call on type, input-field, and enum-value definitions) returns the start of the description block when one is present, not the line of the field name. Build-time validator logs and the R147 LSP diagnostic surface both inherit this: an error on a documented field highlights the opening """ of the doc block rather than the field, which is misleading in the console and visually wrong in the editor squiggle. (updated 2026-05-12)

  • R236 BuildContext nested-input candidate-hint draws from path-origin table instead of @reference terminal table: BuildContext.classifyInputFieldInternal (BuildContext.java:1665-1677) emits a "Did you mean…" hint when a nested-input column name is unresolvable. The candidate list is built from catalog.columnSqlNamesOf(resolvedTable.tableName()) where resolvedTable is the path-origin enclosing input’s @table, not the path’s terminal table. (updated 2026-05-23)

  • R149 End-to-end LSP publish-diagnostics test and buildOutput report-population test for R147: R147’s spec called for two tests that the implementation deferred: (updated 2026-05-12)

  • R209 FieldRegistry classify-input trace loses typed Rejection payload: FieldRegistry.classifyInput at graphitron/src/main/java/no/sikt/graphitron/rewrite/FieldRegistry.java:108-110 emits the trace record for an InputFieldResolution.Unresolved outcome by defaulting to RejectionKind.AUTHOR_ERROR with u.reason() (a String), with the rationale comment "Unresolved carries no Rejection variant …​ default to AUTHOR_ERROR per the kind-of-thumb rule". This is the last place in the input-classification path where the typed-rejection chain breaks: the trace consumers (watch-mode formatter, LSP fix-its) lose the structured attempt + candidates payload they would otherwise consume on a column-miss Unresolved, and on non-column-miss Unresolved they get an AUTHOR_ERROR label that may not match the actual rejection kind. R205 closed the gap one layer up (InputFieldResolver.resolve now lifts to typed Rejection.unknownColumn / Rejection.structural); the corresponding lift inside FieldRegistry.classifyInput was flagged in the R205 self-review and deferred. Two design forks worth thinking through during Spec: (a) widen InputFieldResolution.Unresolved to carry a Rejection (touches every Unresolved construction site in the classifier; some sites lack catalog/candidates context to build unknownColumn), or (b) thread TableRef rt into FieldRegistry.classifyInput and lift to Rejection there. (a) keeps the lift co-located with classification; (b) keeps Unresolved transient by design. Either way the deliverable is removing the RejectionKind.AUTHOR_ERROR default arm and emitting RejectionKind.of(rejection) consistently with traceOutput at FieldRegistry.java:127-130. (updated 2026-05-21)

  • R118 Graphitron MCP server: knowledge-base tools for AI agents: The first non-build consumer of the knowledge base R117 frames. AI agents working in a graphitron-generated codebase ask cross-cutting questions ("what classification produced the fetcher at Film.actors? which roadmap items mention it? which capability does it exemplify? which test exercises it?") that span the SDL, the codegen output, the runtime trace, and the roadmap. Today they grep across half a dozen file trees and reconstruct relationships from prose; the KB already holds these joins as natural-keyed tables, so the MCP server’s job is to expose the joins as tools and let the agent ask SQL or call typed lookups. This item delivers the server, not the schema (which is R117’s programme); the server queries whatever’s in the KB and grows naturally as dimensions land. (blocked by knowledge-base-programme)

  • R201 Honor @field(name:) in @error payload construction shape resolution: FieldBuilder.resolvePayloadConstructionShape (FieldBuilder.java:506-609) picks an @error payload class’s construction shape, then the emitter (catch-arm payloadFactoryLambda in TypeFetcherGenerator, and the validator pre-step’s declareEarlyPayloadFromErrors) generates either an all-fields-ctor invocation or a no-arg-ctor + per-SDL-field setter sequence against it. Neither arm reads @field. The mutable-bean arm at :589-591 matches set<UcFirst(sdlFieldName)> on payloadCls.getMethods() via the Java-bean conversion in javaBeanSetterName; the classifier’s existing invariant at :498-505 pins "setter method name matches the SDL field name under Java-bean conversion" as a contract the emitter relies on. The all-fields-ctor arm at :519-535 picks the single ctor whose parameter count equals sdlFieldNames.size() and the emitter then assumes positional alignment with SDL declaration order. Either way, a payload class whose Java component or setter names diverge from the SDL field names has no remap — exactly the shape R191 now admits on data fields. The fix is to thread @field(name:) on each SDL field into both arms: in the mutable-bean arm, derive the setter base from the directive value when present (set<UcFirst(directiveName)>) and persist the SDL-to-Java mapping on SetterBinding; in the all-fields-ctor arm, the directive value picks the matching parameter (by name where the ctor exposes parameter names, otherwise by the directive’s positional alignment with the record component / canonical-ctor parameter order). Update the classifier invariant text at :498-505 to reflect the new contract (setter / parameter name matches the directive value when present, the SDL field name otherwise). This is the output-side mirror of R200; together they restore symmetry across input bean / record binding (R200) and output payload bean / record construction (this item). (updated 2026-05-20)

  • R202 Honor @field(name:) in @error type extra-field accessor matching against handler source class: FieldBuilder.checkErrorTypeSourceAccessors (FieldBuilder.java:2281-2316) verifies that each @error object type’s extra fields (everything except path / message) can be populated from the handler’s source class (the exception class for a GENERIC handler, the DataAccessException-shaped source for a DATABASE handler, etc.). For each (sdlField, sourceClass) pair it calls ClassAccessorResolver.resolve(sourceClass, sdlField.getName(), expectedReturn, …) with the raw SDL name as the accessor base. When the exception’s accessor diverges from the SDL field name — e.g. an exception exposing getErrorCode() mapped to an SDL field named code, or a Norwegian-named accessor under an English SDL — the resolver returns Rejected and the type fails classification with no author escape hatch. @field(name:) on the SDL extra field is the natural override: this is structurally the R191 case (free-form Java class as the logical "parent", SDL field bound by accessor name, divergent names need a directive remap). The fix is to read DIR_FIELD on each extra sdlField at :2298-2305 and pass the directive value (when present) as the second arg to ClassAccessorResolver.resolve instead of sdlField.getName(). The directive’s docstring already covers this site under the "underlying-binding target" reading; the docstring update tracked separately should mention @error extra fields as one of the free-form-Java-class accessor-axis examples. No emitter change needed — ClassAccessorResolver.Resolved.methodName() is the actual reflected method name, so the runtime invocation path is unaffected. (updated 2026-05-20)

  • R200 Honor @field(name:) in InputBeanResolver for SDL input bean/record member binding: InputBeanResolver (the resolver that populates a @service Java parameter from an SDL INPUT_OBJECT by matching SDL field names to JavaBean setters or canonical record components) reads zero directives. It iterates sdlOrder(iot) or recordOrder(beanClass) and looks up the SDL name in javaMembersBySdlName, built either from setX setter tails (InputBeanResolver.java:353-…) or from RecordComponent.getName() (:338-345). When the Java member name diverges from the SDL field name, the field silently drops on a JavaBean (:303-308 ultimately surfaces "no fields matching the SDL input type") or fails fatally on a record (:260-265). @field(name: "<javaMemberName>") on the SDL input field is the obvious author escape hatch and the directive’s docstring at directives.graphqls:29-39 would naturally cover it ("names the underlying-binding target", which on an INPUT_FIELD_DEFINITION whose parent is a free-form @record-shaped Java bean is the Java accessor / component / setter base). The fix is symmetric to R191’s table-bound / polymorphic-hub thread: read DIR_FIELD on each SDL input field; when present, use the directive value as the lookup key into javaMembersBySdlName instead of the SDL field name. R195’s jOOQ-record arm is a different shape (column → jOOQ TableField → setter walk) and is out of scope here; this item only fixes the consumer-authored bean / Java-record case where the directive should plain-old-name the Java member. The two items together restore @field symmetry across input and output for free-form @record-shaped parents. (updated 2026-05-20)

  • R69 Implement @experimental_constructType: The @experimental_constructType(selection: "…​") directive is declared in directives.graphqls and stripped from the emitted schema by SchemaDirectiveRegistry, but no classifier, model carrier, or emitter exists for it yet.

  • R214 Infer argMapping when the @condition / @service Java signature is unambiguous: ServiceCatalog.reflectTableMethod / reflectServiceMethod bound each Java parameter to a GraphQL argument by name: a parameter matched a same-named field argument, a same-named declared context key, classified as a SOURCES shape, or was rejected with the long "either rename the Java parameter to match … or bind explicitly via the @service directive’s argMapping field" diagnostic. Authors hit this even when the pairing was structurally unique — a field with one argument and a method with one non-Table / non-Context / non-DSLContext parameter has only one possible binding, but the long-form error still required either renaming the Java parameter or writing a redundant argMapping: "javaName: gqlName". The user flagged this as a refusal point in real schemas: (updated 2026-05-21)

  • R90 LSP Javadoc surfacing + per-line definitions + @externalField + argMapping: R18 Phase 5 originally folded three JavaParser-driven capabilities in alongside @service / @condition / @record autocomplete:

  • R99 LSP classpath scan misses sibling modules when dev goal runs from a sub-module: When a consumer runs mvn graphitron:dev from inside one sub-module of a multi-module project (e.g. cd opptak-subgraph && mvn graphitron:dev), Maven loads only that module’s pom and the reactor contains a single project. AbstractRewriteMojo reads MavenSession.getAllProjects() to assemble classpath roots for the LSP catalog scan (the Phase 5e widening that was meant to cover the multi-module case), so the scan only sees the current module’s target/classes. Sibling modules that hold service / condition / record classes referenced by the schema are silently absent: data.externalReferences() is empty, and the LSP returns no completions for @service(service: {className: "…​"}) / @condition / @record, no hover, and no unknown-class diagnostics on those references. Running from the parent with mvn -pl <module> graphitron:dev works because there getAllProjects() returns the full reactor regardless of -pl filtering, but running from inside a module is a normal workflow (faster startup, scoped logs) and the silent failure is the worst-case shape: the user sees an empty popup and no error to grep for.

  • R121 LSP diagnostic for redundant @splitQuery on @record-parent fields: The build-tier warning when @splitQuery is applied to a field whose enclosing type carries @record (shipped per changelog entry 162) is only visible when the user runs mvn install (or hits a build via the IDE’s Maven integration); SDL editing is a much faster feedback loop, and the LSP can surface the same advisory inline at edit time.

  • R253 Make pipeline<→runtime SDL parity test pass: FederationBuildSmokeTest.emittedSdlMatchesRuntimeSchema is currently @Disabled (landed alongside R247). The test asserts, through graphql-java’s SchemaDiffing, that the on-classpath schema.graphqls emitted by SchemaSdlEmitter describes the same schema a consumer rebuilding via Graphitron.buildSchema(…​) sees — the strongest invariant the spec for R247 carried for the artefact, and the one that catches both wrong-printer dispatch and runtime/codegen drift in a single assertion. (updated 2026-05-27)

  • R252 Multi-file federation fixture coverage for schema.graphqls emission: R247’s SchemaSdlEmitter writes target/generated-resources/…​/schema.graphqls by calling ServiceSDLPrinter.generateServiceSDLV2(assembled) on the codegen-time schema. The only pipeline-tier coverage is FederationBuildSmokeTest against sakila’s single-file federated fixture (federated-schema.graphqls, which carries extend schema @link(…​) and types in the same file). Real-world consumer schemas often split across many files — one carrying extend schema @link(…​), others carrying types only, no explicit schema { …​ } block anywhere — and that shape goes through a different code path in RewriteSchemaLoader (MultiSourceReader concatenates streams, single Parser.parseDocument, single SchemaParser.buildRegistry). (updated 2026-05-27)

  • R249 Nested @argMapping syntax via GraphQLSelectionParser: Today’s @argMapping parses as comma-separated javaParam: <path> entries where each right-hand side is a dot-path into the SDL args. To construct a Java record or JavaBean whose fields come from scattered SDL positions (rather than a single anchored input-object), authors have no syntactic recourse: they must either restructure the SDL to mirror their Java type, or carry the binding logic into the service method itself. (updated 2026-05-27)

  • R213 Plain-input field rejections attributed to consumer field, losing input-field source location: When a plain (non-@table) input type carries a broken @condition directive (parameter-binding mismatch, reflection failure, etc.) or an unresolvable column, the failure surfaces as an UnclassifiedField on the consuming field (e.g. Query.opptak), not on the input type’s offending field (e.g. OpptakFilterInput.opptaksNavn). The reported SourceLocation is the consuming field’s definition, so LSP fix-its, watch-mode formatters, and editor highlights point one indirection away from the actual broken directive the author needs to edit. (updated 2026-05-21)

  • R182 Retire legacy reactor and unnest graphitron-rewrite to repo root: Carved out from R26’s "Retire legacy + unnest the rewrite aggregator" sub-item so the repo-topology change has its own Spec and review trail. (updated 2026-05-19) (blocked by retire-maven-plugin)

  • R95 Routines as data-model citizens (jOOQ-native routine support): The rewrite has no support for resolving GraphQL fields from jOOQ-generated routines (functions and stored procedures). Legacy is shipping @experimental_procedureCall (PR #489 in sikt-no/graphitron, on top of the already-merged baf8c10), but it cherry-picks the smallest possible slice of the design space and locks in a shape that does not generalise. The rewrite should treat routines as first-class data-model citizens alongside tables, in the same vocabulary it already uses for @table, @reference, and @field.

  • R170 Sakila execute-tier fixture for the Jakarta ValidationHandler channel (R94-blocked): Split out from R12 (error-handling-parity)'s "Test fixture updates for source-direct dispatch" bullet. R12 lifted ValidationHandler channel support and the Jakarta pre-execution validation step into the per-fetcher catch path. Execute-tier coverage is blocked on R94 (emit-input-records): the pre-step today calls validator.validate(env.getArgument(name)), which receives a Map<?, ?> (or a raw scalar); neither carries Jakarta annotations, so the validator returns no violations and the wire path is a no-op end-to-end. R94 emits each SDL input type as a graphitron-internal Java record under <outputPackage>.inputs, coerces the map at the fetcher boundary, and gives the validator a real annotated bean to inspect; once R94 lands this fixture becomes a one-line addition to the sakila execute-tier driver. (updated 2026-05-16)

  • R152 Scope @nodeId(typeName:) hover column lookup to the @node type’s @table: Hovers.formatNodeType (the hover popup for @nodeId(typeName: "X")) renders X’s NodeMetadata.keyColumns with each column’s graphqlType, but resolves the column type via columnGraphqlType(CompletionData, String) which linear-scans every table in the catalog case-insensitively and returns the first match. The lookup is not scoped to X’s @table-backing table, so when two tables in the catalog hold a column with the same name but different graphqlType projections (e.g. one mapped through a custom scalar via @scalarType, one not; or one nullable and one non-null), the hover renders whichever table the catalog enumerated first. Latent under Sakila (Sakila’s recurring column names map to identical jOOQ-generated graphql types) but a real bug for schemas where same-named columns diverge. The sibling columnHover for @field(name:) / @reference(key:) / @node(keyColumns:) at the directive’s own site already scopes via TypeContext.enclosingTypeDefinition + tableNameOf + catalog.getTable; the R100 hover diverges because the columns it renders belong to a different type than the one the cursor sits in. Fix: extend CompletionData.NodeMetadata to carry the @table name (CatalogBuilder.buildNodeMetadata already walks each @node-bearing GraphQLObjectType and can read the sibling @table directive in the same pass), then formatNodeType looks up catalog.getTable(meta.tableName()) and searches columns inside it, mirroring columnHover’s shape. Add a hover test with two tables that share a column name with diverging `graphqlType to pin the scoping. Surfaced during R100’s In Review → Done review. (updated 2026-05-13)

  • R195 Support jOOQ records as @service input-bean parameters via @field/@nodeId mapping: A @service mutation whose Java parameter is a jOOQ generated *Record (rather than a consumer-authored POJO/record) cannot be populated from an SDL input type today, even when the input carries the legacy @table + per-field @field(name: "…​") + @nodeId(typeName: "…​") directives that already describe the column mapping for the legacy DML path. InputBeanResolver matches SDL field names directly against JavaBean setter names; it consults zero directives. The jOOQ record’s property names are derived from column names (UTDANNINGSSTATUSKODE → property utdanningsstatuskode), so SDL fields named statuskode / fraDato / utdanningsspesifikasjonsId produce zero matches and the binding list is empty, surfacing as "bean class '…UtdanningsspesifikasjonsstatusRecord' has no fields matching the SDL input type 'EndreUtdanningsspesifikasjonsstatusInput'" at InputBeanResolver.java:307. R193 (the sealed-UnresolvedParam classifier) is the natural home for a loud-rejection arm covering this case; this item is the feature that would make it work. (updated 2026-05-20)

Done

See Changelog for the historical record of shipped rewrite work. Plan files are deleted on Done; git history preserves them.