ID |
|
|---|---|
Status |
Spec |
Bucket |
structural |
Created |
2026-05-26 |
Updated |
2026-05-26 |
ErrorChannel walker carrier (R222 Stage 2 slice on @service + @tableMethod)
R222 Stage 2 slice on the output-walking surface, scoped to WithErrorChannel implementers backed by @service or @tableMethod (everything that currently classifies through FieldBuilder.resolveErrorChannel and emits an ErrorChannel.PayloadClass arm). R238 set the conventions for input-walking carriers (MethodCall on MethodBackedField); this slice mirrors the pattern on the output side, walking the SDL payload type rather than the field’s arguments. The slice ships:
-
A reduced
ErrorChannelcarrier ; sealedMapped | NoChanneltwo-arm ; replacing the existingPayloadClass | LocalContextsealed split.Mappedcarries(List<ErrorType> mappedErrorTypes, String mappingsConstantName)and nothing else. The construction-shape branches (payloadClass,errorsSlot,defaultedSlots) and the entirePayloadConstructionShapefamily retire. -
A single producer (
ErrorChannelWalker) that reduces the SDL output payload type: detect the errors-shaped field, identify mapped@errortypes, run channel-level rule checks (today’s rule 7 multi-VALIDATION + rule 8 duplicate match-criteria), reflect on each handler’s source class for per-@error-type accessor coverage, derive the mappings-constant name. Substrate: payloadGraphQLObjectTypeplus the codegen classloader. SubsumesBuildContext.detectErrorsFieldShape,FieldBuilder.resolveErrorChannel,checkChannelLevelHandlerRules,checkDuplicateMatchCriteria,checkErrorTypeSourceAccessors, and the dedup call intoMappingsConstantNameDedup. -
Catch-arm emitter rewrite for the in-scope
WithErrorChannelimplementers. The shape: inline the(Mapping, cause)match loop at every catch site; on match emitreturn DataFetcherResult.<P>newResult().data(null).localContext(List.of(t)).build();. The genericisingErrorRouter.dispatchand per-fetcherpayloadFactorylambda retire.Mapping.match(Throwable)andErrorRouter.redact(Throwable, env)survive as the narrow runtime primitives. -
Validator pre-step rewrite:
declareEarlyPayloadFromErrorscollapses toreturn DataFetcherResult.<P>newResult().data(null).localContext(__violations).build();. ThedeclareEarlyPayload*helper family retires. -
Async tail (
asyncWrapTail) rewrites to the same shape on the.exceptionally(…)path. -
Per-field null-source guards on data-channel
ChildFieldvariants that can sibling anErrorsFieldunder an@service/@tableMethodpayload:PropertyField,RecordField,ServiceRecordField,ServiceTableField,TableMethodField,RecordTableMethodField,ConstructorField,NestingField,ComputedField. Many short-circuit naturally via graphql-java’sPropertyDataFetcher; the audit confirms and adds explicitif (source == null) return null;where missing. -
ChildField.ErrorsField.Transport.PayloadAccessorarm retires; errors universally read viaenv.getLocalContext()for in-scope fields. DML’sTransport.LocalContextarm survives outside scope. -
R201 (
honor-field-directive-in-payload-construction-shape) retires as moot. -
R241 (
retire-error-payloadclass-transport) supersedes-by this item; the umbrella discards. R241’s framing as "retire transport variant + route through LocalContext" was the wrong shape per R222’s dimensional-slot principle; R244 is the same direction reframed correctly.
The DML carriers (MutationDmlRecordField, MutationBulkDmlRecordField) stay on their existing ErrorChannel.LocalContext sentinel-based shape. Their migration onto the same inline emit pattern is a sibling Stage 2 slice filed at this item’s In Progress mark. That follow-on extends the null-source-guard sweep across SingleRecord* data-channel variants, lifts the catch-arm rewrite to those fetchers, and retires ErrorRouter.dispatchToLocalContext, the sentinel emitters (singleRecordSentinelFor, bulkRecordSentinelFor), LOCAL_CONTEXT_GUARDED_DATA_CHANNEL_VARIANTS, and GraphitronSchemaValidator.validateLocalContextErrorsFieldGuards.
Target emitted code
The reducer backtracks from this shape. Every @service or @tableMethod fetcher’s catch arm reads as a literal mapping-walk followed by either the inline match-return or the redact fallback:
// QueryServiceRecordField example, post-slice
public static DataFetcherResult<SakPayload> sak(DataFetchingEnvironment env) {
try {
SakPayload result = SakService.run(...);
return DataFetcherResult.<SakPayload>newResult().data(result).build();
} catch (Throwable e) {
for (Mapping m : ErrorMappings.SAK_PAYLOAD) {
for (Throwable t = e; t != null; t = t.getCause()) {
if (m.match(t)) {
return DataFetcherResult.<SakPayload>newResult()
.data(null)
.localContext(List.of(t))
.build();
}
}
}
return ErrorRouter.redact(e, env);
}
}
The two-deep for is emitted inline at every catch site. graphql-java’s child fetchers for the SDL payload’s data fields read env.getSource() == null and return null via PropertyDataFetcher’s natural null-source behaviour or graphitron’s `@record-accessor lambda. The SDL payload’s errors field’s child fetcher reads env.getLocalContext() and returns the matched throwable list, surfaced through per-@error-type field DataFetchers as today.
Validator pre-step (Jakarta-violation early return) has the same shape:
// inside buildServiceFetcherCommon, pre-step
if (!__violations.isEmpty()) {
return DataFetcherResult.<SakPayload>newResult()
.data(null)
.localContext(__violations)
.build();
}
No __earlyPayload local, no payload-class construction.
Async tail (.exceptionally(…) on CompletableFuture<P>-shaped child fetchers) lifts the body into a lambda over the throwable, otherwise identical.
Slot landing on WithErrorChannel
The slot’s home is the existing WithErrorChannel interface, which already names "fetcher-emitting field that may carry a typed-error channel." The slice replaces the interface’s single accessor:
// graphitron/src/main/java/no/sikt/graphitron/rewrite/model/WithErrorChannel.java
public interface WithErrorChannel {
ErrorChannel errorChannel(); // replaces Optional<ErrorChannel>
}
The carrier is sealed Mapped | NoChannel; consumers pattern-match exhaustively and always get a populated arm. R222’s "no Optional slots" applies: the slot is universal across WithErrorChannel implementers, presence is payload-shape-gated rather than directive-gated on the implementer, so the No<Family> arm is the right encoding for absence (rather than interface non-membership, which R238 uses for directive-gated cases).
DML temporary split. DML carriers (MutationField.MutationDmlRecordField, MutationField.MutationBulkDmlRecordField) currently implement WithErrorChannel and populate the legacy LocalContext arm. In this slice’s scope they keep their behaviour, but they no longer fit the reduced Mapped | NoChannel shape. The pragmatic resolution: split off a sibling WithDmlErrorTransport interface carrying the legacy sentinel-based transport (a new DmlErrorTransport record, lifted verbatim from the existing ErrorChannel.LocalContext). DML field permits move from WithErrorChannel to WithDmlErrorTransport. The split is acknowledged as temporary; the DML-absorption follow-on re-unifies them under WithErrorChannel when the DML catch-arm rewrites to the inline shape.
Carrier shape
// graphitron/src/main/java/no/sikt/graphitron/rewrite/model/ErrorChannel.java
public sealed interface ErrorChannel permits ErrorChannel.Mapped, ErrorChannel.NoChannel {
record Mapped(
List<GraphitronType.ErrorType> mappedErrorTypes,
String mappingsConstantName
) implements ErrorChannel {
public Mapped {
mappedErrorTypes = List.copyOf(mappedErrorTypes);
if (mappedErrorTypes.isEmpty()) {
throw new IllegalArgumentException(
"ErrorChannel.Mapped: mappedErrorTypes must be non-empty");
}
}
}
record NoChannel() implements ErrorChannel {}
}
The carrier dissolves ErrorChannel.PayloadClass entirely (with its payloadClass, errorsSlot, defaultedSlots components) and absorbs the meaningful payload of ErrorChannel.LocalContext into Mapped. The existing ErrorChannel.LocalContext record moves to a new top-level type DmlErrorTransport outside the sealed root, with identical fields. The shared accessors that today live on the sealed ErrorChannel root (mappedErrorTypes, mappingsConstantName) retire ; consumers pattern-match on the carrier and read off the Mapped arm directly.
Producer (ErrorChannelWalker)
// graphitron/src/main/java/no/sikt/graphitron/rewrite/walker/ErrorChannelWalker.java
public final class ErrorChannelWalker {
public WalkerResult<ErrorChannel> walk(
GraphQLObjectType payloadType,
ClassLoader codegenLoader,
MappingsConstantNameDedup dedup
);
}
Substrate is the SDL output payload type directly, paired with the codegen classloader for handler-class reflection and the build-scoped name-dedup helper. This is the output-walking analogue of R238’s input-walking MethodCallWalker: same producer-as-thin-layer-over-graphql-java pattern, different SDL surface.
Walker stages (one pass per WithErrorChannel field’s payload type):
-
Find the errors-shaped field on the payload. Walk
payloadType.getFieldDefinitions()in source order; the first field whose shape matches the "polymorphic list/union/interface of@errortypes" predicate is the errors carrier. SubsumesBuildContext.detectErrorsFieldShapeand theliftToErrorsFieldlift rules. Absence: emitOk(NoChannel); the fetcher’s catch arm routes throughErrorRouter.redact(e, env). -
Identify mapped
@errortypes. Extract the@errortypes from the field’s polymorphic shape (list element, union members, interface implementations). Non-empty by structural rule; one-element for[SomeError], multi-element for unions / interfaces. -
Run channel-level rules. Rule 7 (no two VALIDATION handlers in one channel), rule 8 (no duplicate match-criteria across the flattened handler list). Failure: typed
AuthorError.Structural.ChannelRuleViolation(payloadTypeName, errorsFieldName, ruleNumber, detail)arm. -
Reflect on handler source-classes for accessor coverage. Per (channel,
@errortype, handler), walk the@errortype’s declared SDL fields and verify the handler’s source class exposes aPropertyDataFetcher-visible accessor.pathandmessageare exempt (populated by per-@error-type synthesised DataFetchers). SubsumescheckErrorTypeSourceAccessors. Failure: typedAuthorError.Structural.HandlerSourceAccessorMissing(…)arm. -
Resolve the mappings-constant name. Use
MappingsConstantNameDedup: derive fromSCREAMING_SNAKE(payloadSdlName); on collision, append the 8-hex SHA-256 suffix per the existing dedup rules. The name is build-scoped, so the walker takes the dedup helper as a constructor arg. -
Emit result.
Ok(Mapped(types, constName), [])on success;Ok(NoChannel(), [])when no errors-shaped field found;Err(authorErrors, diagnostics)on any structural failure.Errcollects across stages; the walker doesn’t short-circuit at the first failure.
Invocation point. The walker is invoked from FieldBuilder at each constructor site for a WithErrorChannel implementer in scope. Today’s sites pass through resolveErrorChannel (which returns a stringly-typed ErrorChannelResult); under the slice, those sites collapse into a single walker call. R238’s "no fallback to UnclassifiedField`" applies: `Err paths surface through WalkerResult.Err.errors and the orchestrator’s diagnostic stream; the field is excluded from the classified set. The reflection-shape rejections never construct UnclassifiedField for the channel-rejection paths.
Unit-testability mirrors R238: parse an SDL fragment, configure a small test ClassLoader with fixture handler / @error-source classes, call walk, assert on the sealed result.
Consumer migration
All WithErrorChannel implementers in scope read field.errorChannel() and pattern-match the sealed arm:
WithErrorChannel implementer |
Consumer entry point (TypeFetcherGenerator) |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Shared emitter. A new utility encapsulates the catch-arm body emission, parameterised on the carrier:
// graphitron/src/main/java/no/sikt/graphitron/rewrite/generators/ChannelCatchArmEmitter.java
public final class ChannelCatchArmEmitter {
public static CodeBlock emit(ErrorChannel channel, TypeName payloadType, String outputPackage);
}
Dispatches on the sealed carrier exhaustively:
-
Mappedarm: emit the inline mapping-walk loop + match-return +redactfallback shown in the target-code section. -
NoChannelarm: emitreturn ErrorRouter.redact(e, env);(the redact-only catch arm).
asyncWrapTail consumes the same emitter through a small wrapping context that lifts the result into the .exceptionally(…) lambda. The validator pre-step (emitJakartaValidatorPreStep) consumes a sibling helper ChannelEarlyReturnEmitter.emit(channel, payloadType, violationsLocal) that emits the same shape for the violations-list early return.
TypeFetcherGenerator.catchArm, dispatchCatchArm, payloadFactoryLambda, payloadFactoryLambdaCtor, payloadFactoryLambdaSetters, declareEarlyPayloadFromErrors, declareEarlyPayloadCtor, declareEarlyPayloadSetters retire at this seam.
Per-field null-source guard sweep
The catch arm emits data(null), so each in-scope payload’s data-channel child fetcher must short-circuit on null source. graphql-java’s PropertyDataFetcher.fetching(name) returns null on null source naturally; graphitron’s @record-accessor lambdas under FetcherEmitter.propertyOrRecordValue likewise. The audit confirms the gap and adds explicit if (source == null) return null; to the variants below where missing:
-
ChildField.PropertyField,ChildField.RecordField: read throughPropertyDataFetcheror@record-accessor; usually safe but audit confirms. -
ChildField.ServiceRecordField,ChildField.ServiceTableField: child@servicefetchers; need explicit guard so the service method isn’t called with a null source row. -
ChildField.TableMethodField,ChildField.RecordTableMethodField: child@tableMethodfetchers; same. -
ChildField.ConstructorField,ChildField.NestingField:env.getSource()passthrough; null-source already returns null implicitly. -
ChildField.ComputedField: accessor call on source; needs explicit guard.
Variants outside scope (DML SingleRecord* family) are unchanged; they retain their existing sentinel-based behaviour until the DML-absorption follow-on.
AuthorError sub-arms and LSP codes
Following R238’s pattern. AuthorError.Structural grows sub-arms for the error-channel rule violations:
sealed interface Structural extends AuthorError permits
Structural.ChannelRuleViolation,
Structural.HandlerSourceAccessorMissing,
/* ...existing arms... */,
Structural.Other
{ String message(); }
record ChannelRuleViolation(
String payloadTypeName,
String errorsFieldName,
int ruleNumber, // 7 or 8 today; future rules slot in
String detail
) implements Structural {}
record HandlerSourceAccessorMissing(
String payloadTypeName,
String errorTypeName,
String handlerClassName,
String missingFieldName,
List<String> available
) implements Structural {}
Arm-to-code mapping:
AuthorError arm |
LSP code |
|---|---|
|
|
|
|
|
|
source: "graphitron", severity Error, primary SourceLocation is the payload type’s SDL location (or the errors field’s location when the rule applies at the channel level). Offending handler details (per-@error-type, per-handler) go in Diagnostic.relatedInformation.
R238’s Structural.Other(String reason) transitional catch-all covers any channel-rejection callsite this slice doesn’t sub-arm; follow-ons retire Other callsites as they migrate.
What retires
Model:
-
ErrorChannel.PayloadClassarm (whole record). -
ErrorsSlot,DefaultedSlot,NonBoundSetter(records + their files). -
PayloadConstructionShapesealed family +AllFieldsCtor,MutableBean,SetterBinding. -
ChildField.ErrorsField.Transport.PayloadAccessorarm (onlyLocalContextsurvives, only for DML). -
WithErrorChannel’s `Optional<ErrorChannel>accessor. -
ErrorChannel’s shared `mappedErrorTypes()/mappingsConstantName()accessors on the sealed root.
Classifier:
-
FieldBuilder.resolvePayloadConstructionShape+tryMutableBean+ helpers (formatCtorSignatures,javaBeanSetterName). -
FieldBuilder.resolveErrorChannel+buildErrorChannelCtorArm+buildErrorChannelBeanArm+collectDefaultedSlots+collectNonBoundSetters+defaultLiteralFor. -
FieldBuilder.checkChannelLevelHandlerRules,checkDuplicateMatchCriteria,checkErrorTypeSourceAccessors(absorbed into the walker). -
BuildContext.detectErrorsFieldShape(moves into the walker as an internal helper).
Emitter:
-
TypeFetcherGenerator.payloadFactoryLambda,payloadFactoryLambdaCtor,payloadFactoryLambdaSetters. -
TypeFetcherGenerator.declareEarlyPayloadFromErrors,declareEarlyPayloadCtor,declareEarlyPayloadSetters. -
TypeFetcherGenerator.dispatchCatchArm(replaced byChannelCatchArmEmitter.emit). -
TypeFetcherGenerator.catchArmoverload split (one shape now; ctor-arm specifics gone). -
ErrorRouter.dispatch(the generic per-fetcher payload-factory router).Mapping.match(Throwable)survives;redact(Throwable, env)survives.
Audit annotations:
-
@LoadBearingClassifierCheck(key = "payload-construction.*")producer annotations + their@DependsOnClassifierCheckconsumer pairs.
Tests:
-
PayloadConstructionShapeTest(7 cases) deletes. -
FetcherPipelineTest’s R154 cases (`serviceMutation_setterShapePayload_emitsSetterFactory,_allFieldsCtorPayload_emitsCtorFactory_unchanged,_bothShapesPresent_prefersCtorFactory,dmlMutation_setterShapePayload_emitsSetterFactory): rewrite around the new emission shape or delete (some become irrelevant under inline emission). -
ErrorRouterClassGeneratorTest’s tests for the retired `dispatchmethod delete;redactandMapping.matchtests survive. -
ErrorChannelClassificationTest’s positive cases rewrite to assert on `WalkerResult.Ok(ErrorChannel.Mapped); rejection cases rewrite to assert on the typedStructural.*sub-arms viaWalkerResult.Err. -
New
ErrorChannelWalkerTest(unit tier) andChannelCatchArmEmitterTest(unit tier) mirror R238’sMethodCallWalkerTest/MethodCallEmitterTeststructure: one positive arm perMappedshape, one perStructural.*rejection, one forNoChannel. -
Execute-tier
GraphQLQueryTestcases for the catch-arm round-trip continue to pass without changes (the architectural shift is structurally invariant on observable behaviour).
Documentation:
-
error-handling-parity.mdsections describing the PayloadClass transport and the construction-shape escape hatches retire. New section pins the contract: "After R244, all@service/@tableMethodpayload error transport is the inlinedata(null).localContext(…)shape; the generator never constructs the developer’s payload class on the error path." -
Javadocs across
TypeFetcherGenerator,FieldBuilder,WithErrorChannel,ChildField.ErrorsField,ErrorRouterClassGeneratorupdate to reflect the single-arm carrier and inline catch-arm shape.
Tests
Three tiers, mirroring R238:
-
Unit (
@UnitTier):ErrorChannelWalkerTest(~10 cases: one perMappedsource shape ; single@error, union, interface, list; one perStructural.*arm ; rule 7 / rule 8 / handler-accessor-missing;NoChannelwhen payload has no errors-shaped field) andChannelCatchArmEmitterTest(3 cases:Mappedbody shape,NoChannelbody shape, async-tail lambda wrapping). -
Pipeline (
@PipelineTier): extendErrorChannelClassificationTestwith assertions onwalkerDiagnosticsinstead ofRejection.structuralprojection for channel-rejection failures. Existing positive-witness tests keep their author-facing wording but assert against typedStructural.*arms. Pipeline cases asserting fetcher body-string content fail under the new emission shape (inline mapping-walk vs the oldErrorRouter.dispatch(…)single-statement call); perrewrite-design-principles.adoc’s ban on code-string assertions, those assertions retire here and get replaced by structural assertions on the `ErrorChannel.Mappedcarrier. -
Compilation / Execution (
@CompilationTier/@ExecutionTier):graphitron-sakila-exampleregression net. The migration is structurally invariant on observable response shape ({film: null, errors: […]}vs the old constructed-payload{film: null, errors: […]}are identical at the GraphQL wire), somvn install -Plocal-dbend-to-end-green is the safety net.
Out of scope
-
DML migration (
MutationDmlRecordField,MutationBulkDmlRecordField): files separately at this item’s In Progress mark. Scope: extend null-source guards acrossSingleRecord*, rewrite the DML catch arm to the inline shape, retiredispatchToLocalContext+singleRecordSentinelFor+bulkRecordSentinelFor+LOCAL_CONTEXT_GUARDED_DATA_CHANNEL_VARIANTS+validateLocalContextErrorsFieldGuards+DmlErrorTransport(re-unify withWithErrorChannel). -
@condition-bound paths: not inWithErrorChannel(they don’t emit fetchers). -
Universal
UnclassifiedFieldretirement: R222 Stage 4. This slice retiresUnclassifiedFieldfor the error-channel rejection paths only; broader retirement is per other carrier slices. -
The
DataFetcherBuilderdimensional slot composition: R222 Stage 3. This slice ships the walker carrier and the shared emitter, not the dimensional slot that would composeMethodCall×ErrorChannel× … into one emit-ready form. -
Structural.Othercallsites outside the in-scope rejection paths: untouched, projecting through the fallback per R238’s transitional pattern.
Supersedes
-
R241 (
retire-error-payloadclass-transport, Spec): discarded. R241’s framing ("retire transport variant, route through LocalContext") was the wrong shape per R222’s dimensional-slot principle ; no transport carrier should survive at all. R244 reframes the same direction as a Stage 2 walker-carrier slice. TheSlettPoengformelPayloadincident that motivated R241 lands as a non-event after R244 ; the generator never reflects on the payload class. -
R201 (
honor-field-directive-in-payload-construction-shape, Backlog): moot. The construction-shape machinery R201 targets retires here.