Why a classifier failed to produce a model variant is a typed value, not a string. Every builder-step lift returns a sealed Resolved result; the rejection arm carries a Rejection instance whose variant tells the validator and downstream consumers (LSP fix-its, watch-mode formatters) what kind of failure happened and what data accompanies it. Switches across success and failure modes are exhaustive at compile time; relaxing a producer surfaces as a missing-arm error in every consumer, not a runtime surprise.

This page is the chapter narrative for that contract. Reference detail (the per-leaf record components, the per-resolver Resolved arms) lives on the source: javadoc on Rejection and on each *DirectiveResolver.Resolved carries the per-permit data shape.

The builder-step and resolver surfaces this page walks belong to the transitional classification walk (see the Pipeline overview for the strangler frame); the typed Rejection contract itself is not transitional. Store-derived detections already project through it: AuthoredClaimConflicts reads the store’s conflict view and produces the same located, typed ValidationError`s the dissolved walk-side detector sites did, and violations additionally land as located facts in the store’s diagnostics stratum, read back through the `diagnostic view. As producers migrate off the walk, what changes is where a rejection is detected, not the typed shape it rides.

Sealed Resolved across the resolver siblings

The directive resolvers share the same shape. Each entry point returns a sealed Resolved whose arms split success from failure; the caller’s switch is exhaustive across both. The arm names vary by domain (the @lookupKey resolver has only Ok / Rejected; the @service resolver fans Ok into typed sub-arms because the downstream emitters need different model types per success kind), but the contract is uniform: rejection is a typed sibling of success, not a return-string-or-null.

Where a rejection comes from is at the boundary of one builder step; how it surfaces to the user is at the boundary of the validator. In between, the typed value rides on the result without losing structure: a Rejection.AuthorError.UnknownName arm carries its attempt, its candidates, and a typed AttemptKind tag identifying the lookup space, all the way from the resolver that built it to the LSP fix-it that reads them off the wire.

The validator does not parse prose. It switches on the Rejection variant, formats the human-readable line with message(), classifies it as AUTHOR_ERROR or INVALID_SCHEMA or DEFERRED for the diagnostic surface, and emits. The same typed value drives every consumer: the validator’s log, the LSP fix-it, an editor’s hover-card, a CI annotation. None of them re-parse text.

Rejection taxonomy

The Rejection sealed hierarchy splits along who can fix this: the schema author, the runtime author who hasn’t shipped support yet, or the schema as a whole.

Rejection
├─ AuthorError                  ← the schema author can correct this
│   ├─ UnknownName              ← name that didn't resolve against a closed set
│   ├─ Structural               ← rule violation carrying prose only
│   ├─ AccessorMismatch         ← record-backed parent's class doesn't expose the accessor
│   ├─ RecordBindingMultiProducer ← multiple producers reach one SDL type with disagreeing classes
│   ├─ TypeConflict             ← cross-site contextArgument type-agreement disagreement
│   ├─ MultiProducerDomainTypeDisagreement ← producers reach one SDL Object type with disagreeing env.getSource() Java domain types
│   ├─ SortEnumMissingOrder     ← an @orderBy sort enum value declares neither @order nor @index
│   ├─ TenantColumnTypeDisagreement ← the configured tenant column carries disagreeing Java types across catalog tables
│   └─ NoTenantBinding             ← a field reaches a tenant-scoped table with no tenant binding in scope
├─ InvalidSchema                ← the schema can't accept this combination at all
│   ├─ DirectiveConflict        ← two directives co-occur in a rejected combination
│   ├─ CaseFoldCollision        ← two type names are equal under case-folding
│   └─ Structural               ← rule violation carrying prose only
└─ Deferred                     ← classifies cleanly but the generator hasn't emitted support yet

AuthorError.UnknownName is the data-rich arm: it carries the attempt the author wrote, the candidates the catalog had at this site (column names, table names, FK names, service-method names, …​), and an AttemptKind tag for downstream tooling. The kind is a typed tag rather than an arm split because every space carries the same (attempt, candidates) shape; an LSP fix-it that wants to offer "rename to one of these" reads attempt and candidates directly off the rejection without parsing the validator’s prose.

AuthorError.Structural and InvalidSchema.Structural are the prose-majority arms. They carry one String reason and exist because most rule violations are not name-against-closed-set lookups; they’re "this combination cannot work, period" or "this rule was broken." Two arms instead of one because the diagnostic surface treats AUTHOR_ERROR and INVALID_SCHEMA differently: the first prompts the author to edit; the second prompts the author to drop or replace a directive entirely.

AuthorError.AccessorMismatch is the third AuthorError arm because the record-backed-parent accessor-resolution surface (resolving an SDL output field’s accessor against the parent’s reflection-derived backing class) produces uniform diagnostics with a uniform fix shape (@field(name: "…​")); a single arm with the hint baked into message() lets the resolver hand the validator the same typed value every site produces.

AuthorError.RecordBindingMultiProducer is the fourth AuthorError arm, surfacing the producer-agreement check: when two or more producers (root producers or parent-accessor chains) reach the same SDL type with disagreeing reflected backing classes, the validator halts with a typed payload naming the SDL type and every disagreeing ProducerBinding site. The arm sits under AuthorError because the fix is author-correctable (align the producers on a single backing class via the same rename / retype / split toolbox the rest of AuthorError supports); the typed List<ProducerBinding> payload follows the sub-data pattern that UnknownName established.

AuthorError.TypeConflict is the fifth AuthorError arm, surfacing the cross-site contextArgument type-agreement check: when two or more directive sites (@service, @condition) reference the same contextArgument name with mutually-incompatible Java types, the schema-driven Graphitron.newExecutionInput(…​) factory cannot produce a single typed parameter slot. The arm carries the contextArgument name plus the typed List<ConflictSite> (each site’s MethodRef coordinate and the TypeName that site declared); message() renders one indented line per site for the validator’s prose surface, while LSP fix-its read the typed sites field directly. Like RecordBindingMultiProducer, the arm sits under AuthorError because the fix is author-correctable (align every site on a single Java type).

AuthorError.MultiProducerDomainTypeDisagreement is the sixth AuthorError arm, surfacing the uniform-domain-return-type check: when two or more OutputField producers reach the same SDL Object return type with disagreeing DomainReturnType.Claim arms (Record(table) vs TableRecord(class) vs Plain(class)), the producers put structurally different Java values at env.getSource() for the SDL type’s child datafetchers. The generator commits to one source-Java-type per child-field coord at emit time and does not branch on runtime source type, so a runtime disagreement would feed a datafetcher generated against the other producer’s record shape.

The comparison spans the producers that state a claim, not every producer. DomainReturnType’s root splits into `Claim and NoClaim, and a producer answers NoClaim when nothing in the model names the Java value it hands down: a polymorphic return chosen at run time, an errors list of developer exception classes, a component read of a type that never grounded a backing class. Unverifiable is not disagreeing, so a no-claim is excluded from the comparison; it stays in the participant list, rendering as "makes no source-type claim", because the author’s first question when a conflict does fire is who else produces the type. Excluding it is what makes the shared class-backed value type authorable: a type produced by a @service and read as a component of a record-backed parent is one Java object reaching one generated fetcher, and the placeholder answers that used to stand in for "cannot say" could never compare equal.

What the arm still enforces is cross-arm, the projection-vs-typed-record-vs-domain-object axis: class identity within the Plain arm for a class-backed SDL type is enforced upstream by RecordBindingResolver’s per-type binding fold, which compares reflected `Class identity and surfaces RecordBindingMultiProducer. The arm carries the SDL type name plus a typed List<Participant> (each producer’s (parentTypeName, fieldName, DomainReturnType)); message() renders one indented line per participant. The headline case is the carrier-payload conflict (DML @mutation returning Record(table) vs @service returning a typed TableRecord for the same payload SDL Object); the constraint is general across any current or future producer permit.

AuthorError.SortEnumMissingOrder is the seventh AuthorError arm, surfacing the per-value ordering-completeness check: when a sort enum bound to an @orderBy argument carries one or more values declaring neither @order nor @index, OrderByResolver would silently skip each such value and produce no NamedOrder, so a request selecting only unannotated values generates an empty ORDER BY, and on a paginated connection keyset pagination then slices a nondeterministic set (rows duplicate or vanish across pages). The rejection fires at the parse boundary in OrderByResolver.resolveOrderByArgSpec, making the empty-ORDER-BY state unrepresentable in the model. The arm carries the sort enum’s type name plus the typed List<String> of unannotated value names, accumulated across the whole enum in declaration order rather than fail-fast so the author sees every missing value at once; message() renders one indented line per value. Like RecordBindingMultiProducer, the arm sits under AuthorError because the fix is author-correctable (annotate each value with @order), and the typed value list rides so LSP fix-its read the missing set without parsing prose. An empty @order, and @order+@index coexistence, are a sibling gap this arm does not cover; it is named so a shared order-directive family can fold both in later.

AuthorError.TenantColumnTypeDisagreement is the eighth AuthorError arm, surfacing the tenant-scope classification’s type-agreement check: the tenant Java type is read off the jOOQ catalog’s column type rather than configured, and every routed acquisition keys the per-tenant DataSource map with a value of that one type, so two tables carrying the configured <tenantColumn> with disagreeing column types would make the divined key’s type depend on which table a field happens to bind through. The rejection fires from TenantScopeClassifier at catalog load, before any per-field binding is computed. The arm carries the configured column name plus a typed TableSite list (each carrying table’s schema-qualified SQL name and the TypeName its column declares, in the catalog’s stable schema-then-table order); message() renders one indented line per table. It sits under AuthorError because the fix is author-correctable: align every table’s column on a single Java type, or point <tenantColumn> at a column the catalog agrees on. The sibling defect of a configured column no table carries reuses AuthorError.UnknownName with the COLUMN attempt kind rather than minting a new arm, because it is exactly a name that failed to resolve against a closed set.

AuthorError.NoTenantBinding is the ninth AuthorError arm, surfacing the per-field tenant-binding fold’s completeness rule: a field (or a node-id / federation-entity dispatch surface) reaches a tenant-scoped table, but no argument or input-object field maps to the tenant column, the decoded batch key does not embed it, and no ancestor established a tenant context on every reaching path. Routing tenant data through the default connection because nothing named the tenant is exactly the cross-tenant leak the tenant-binding axis exists to prevent, so the absence is a build error, never a silent fallback. The arm carries the offending coordinate (a Type.field pair, or a type name for a dispatch surface), the tenant-scoped table reached, and a detail sentence naming which route failed to bind; message() appends the actionable fix (bind an argument or input field to the tenant column on the field or an ancestor). The rejection fires from the post-walk tenant-binding fold and drains through the validator’s tenant mirror beside TenantColumnTypeDisagreement.

ServiceMethodCallError is a sub-seal of AuthorError, scoped to the @service-binding failures of the ServiceMethodCallWalker / ServiceCatalog.reflectServiceMethod path that projects @service directive sites onto the ServiceMethodCall carrier on root sync service permits (QueryServiceTableField, QueryServiceRecordField, MutationServiceTableField, MutationServiceRecordField). Each typed arm carries the structural data its diagnostic message needs and a stable lspCode() under the graphitron.service-method-call. namespace; downstream tooling switches on the arm rather than parsing prose. The service-binding failures ServiceCatalog once produced as AuthorError.Structural prose are typed arms here, and the reflection-intrinsic failures (class-load, return-type, parameter-names, overload) are partitioned into the sibling ReflectionError sub-seal (below) so a @condition / @externalField failure of the same shape is not forced through a @service-named arm. Subsequent walker slices (condition, externalField) each add their own sibling sub-seal alongside this one, keeping the dimensional pivot one-row-per-walker rather than piling typed arms under a single flat Structural.

ServiceMethodCallError.MultipleDslContextSlots fires when a single round (constructor or method) carries more than one DSLContext parameter slot; carries className and a Round enum identifying which round violated the invariant (the constructor round is reachable, since the holder may bind a multi-parameter constructor). ServiceMethodCallError.ParameterUnbindable fires when a Java parameter slot does not match any GraphQL argument, declared context key, or DSLContext slot; carries paramName, the available argument names, and a Levenshtein-ranked suggestion. ServiceMethodCallError.InstanceHolderUnconstructible fires when an instance @service method’s enclosing class cannot be used as a holder, either because it is abstract / an interface or because it exposes no public constructor whose parameters are each a DSLContext or a declared context argument; carries the class/method coordinate, the class’s simple name (for the fix hint), and a HolderProblem discriminant. ServiceMethodCallError.ArgumentParameterMismatch fires when a Java parameter matches no GraphQL argument or context key; carries the parameter and method names, the available argument names and context keys, and a pre-rendered rename / argMapping / dot-path suggestion (subsumes the prose the legacy Structural arm produced at ServiceCatalog). ServiceMethodCallError.DtoSourcesUnsupported fires when a @service SOURCES parameter is a List<DTO> / Set<DTO> whose element is not backed by a jOOQ TableRecord; carries the parameter and method names plus the @sourceRow hint. ServiceMethodCallError.UnrecognizedSourcesType fires when a parameter looks like a SOURCES batch shape but its element type is none the classifier recognises; carries the parameter and method names plus the unrecognised Java type name. ServiceMethodCallError.SourcesOnPkLessParent fires when a @service SOURCES batch parameter sits on a child coordinate whose parent table declares no primary key, so there is no batch key to build; carries the parameter and method names plus the parent type and table. It is the discriminated half of a coordinate shape that used to be conflated with the root case: both arrive at the classifier with an empty parent-PK list, but only the root one is genuinely parentless, and the other was falling through to ArgumentParameterMismatch, which prescribes a rename that cannot help.

ReflectionError is a sub-seal of AuthorError for the reflection-intrinsic failures shared across ServiceCatalog’s three reflect helpers (`reflectServiceMethod, reflectTableMethod, reflectExternalField). A class that cannot be loaded, a method whose return type does not match the field’s declared type, a class compiled without -parameters, or an overloaded method name are properties of the reflected Java method regardless of which directive references it, so these arms live under the graphitron.reflect. namespace rather than being forced through ServiceMethodCallError. Like its siblings it carries one stable lspCode() per arm. ReflectionError.ClassNotLoaded fires when the referenced class cannot be loaded through the codegen classloader; carries the binary class name. ReflectionError.ReturnTypeMismatch fires when the reflected return type does not equal the type the field’s declared return requires; carries the class/method coordinate and the expected vs. actual type in their message-surfaced simple form. ReflectionError.ParameterNamesMissing fires when the class was compiled without -parameters so a parameter that needs its name to bind has none; carries the class/method coordinate. ReflectionError.AmbiguousMethod fires when more than one declared method shares the referenced name (the reflect helpers previously took the first JVM-declaration-order match silently); carries the class/method coordinate and every same-name declaration’s parameter arity. The session-hook resolution (the <sessionState> <mount>/<unmount> method references, reflected through the same pickMethod seam with the seam filter as an explicit input) adds five arms. ReflectionError.SeamParameterMissing fires when no declaration of the referenced hook method carries exactly one seam parameter (org.jooq.Configuration or java.sql.Connection); the seam rule is also the overload selector, so this covers both a single method with zero or several seam-typed parameters and an overload set with no qualifying candidate, and it carries every same-named candidate’s rendered parameter list. ReflectionError.SeamCandidateAmbiguous fires when several same-named declarations each carry a seam parameter, so the selector cannot pick one; carries the qualifying candidates' rendered parameter lists. ReflectionError.HookNotStatic fires when the referenced hook method is not public static; the generated hook class emits a direct ClassName.method(…​) call, so instance hooks are unsupported by construction. ReflectionError.HookThrowsChecked fires when a hook method declares a checked exception; a session hook has no field coordinate and no @error channel to route through, so declared checked exceptions have nowhere to land, and unchecked failures propagate into the fail-closed connection eviction instead. ReflectionError.HandleTypeMismatch fires when the <unmount> method’s non-seam parameter does not accept the <mount> method’s reflected return type; both are the consumer’s own declarations, so the message names both real signatures (an unmount taking only the seam is always legal and never reaches this arm).

UpdateRowsError is a sub-seal of AuthorError, scoped to the UpdateRowsWalker that projects an @mutation(typeName: UPDATE) field’s input onto the UpdateRows carrier riding the Update write arm (OperationMember.Write.Update, carried by MutationField.DmlTableField and the record carriers; the input’s fields resolve against the field’s write-target table). Each typed arm carries the structural data its diagnostic message needs and a stable lspCode() under the graphitron.update-rows. namespace; downstream tooling switches on the arm rather than parsing prose. Like ServiceMethodCallError, it is a sibling sub-seal of AuthorError rather than a set of arms under the flat Structural, keeping the dimensional pivot one-row-per-walker. The arms subsume the per-input-field and PK-coverage prose the legacy MutationInputResolver produced for the UPDATE path.

UpdateRowsError.NoUniqueKeyCoverage fires when no primary key and no unique key has its column set covered by the input’s columns; carries the table name, the input-covered columns, and every candidate key the walker considered (a table with no keys at all is the degenerate empty-candidate case). UpdateRowsError.NoSetFields fires when every input field contributes to the matched key, leaving an empty SET; carries the table name and the matched key. UpdateRowsError.MixedCarrierKeyMembership fires when a single own-columns carrier’s lifted columns straddle the matched key (some are key members, some are not); carries the field name and the in-key / outside-key column split. A carrier’s own columns are this row’s identity, so writing only some of them would move the row rather than update it. Neither reference carrier reaches this arm. A self-FK @nodeId @reference routes its lifted columns wholly to the SET partition, since they are a pointer to a sibling row and never identity. A cross-table FK reference partitions per column: its out-of-key columns are SET writes, and its in-key columns stay identity, supplying the WHERE predicate where nothing else does and otherwise contributing only an agreement obligation. Either way a column landing in both partitions is reconciled by an emit-side value-agreement check.

UpdateRowsError.NullableStraddlingReference fires when such a straddling cross-table reference is nullable; carries the field name and location, the write target, the matched key, and the in-key / outside-key column split. An explicit null would write NULL into the out-of-key half of the foreign key and leave the in-key half alone, because that half is row identity and is never written; PostgreSQL’s default MATCH SIMPLE treats a partially-null foreign key as satisfied, so the constraint would not catch it and the row would keep a dangling half-key. The rule the arm states is that the in-key half of a straddling reference is identity, so the reference can be re-pointed only within the same key value and can never be cleared: spell it ID!. It carries the matched key and write target because the rejection is not a property of the field alone, the same nullable spelling being legal wherever the matched key does not intersect the foreign key. It is a separate permit from MixedCarrierKeyMembership rather than a widening of it, because lspCode() is the contract downstream tooling switches on and "don’t straddle your own key" and "make this reference non-null" are different fixes. UpdateRowsError.UnsupportedInputFieldShape fires for nesting fields, unbound fields without an override condition, or any non-admitted carrier; carries the field name, the classifier shape, and a reason. UpdateRowsError.OverrideConditionNotSupported fires when an input field carries @condition(override: true) (the shape is admitted at classify time, but its emit-side never landed, so the filter would silently never run); carries the field name and the directive’s source location. UpdateRowsError.PlainColumnCollision fires when two or more plain @field writers (no @nodeId decode among them) resolve to one SET column, which the single-row Map.put would silently last-write-wins and the bulk VALUES-join would crash on a duplicate derived column; carries the two field names and the column. The INSERT path rejects the same shape on its own SET-map ground; the @service jOOQ-record path does not, since it admits a declared @deprecated-alias group (see JooqRecordInputError below), so this arm states its mechanism rather than mirroring a sibling. An overlap involving a decode is admitted and reconciled by the runtime value-agreement check rather than rejected here.

DeleteRowsError is a sub-seal of AuthorError, scoped to the DeleteRowsWalker that projects an @mutation(typeName: DELETE) field’s input onto the DeleteRows carrier riding the Delete write arm (OperationMember.Write.Delete, carried by MutationField.DmlTableField and the record carriers; the input’s fields resolve against the table named by @mutation(table:)). Each typed arm carries the structural data its diagnostic message needs and a stable lspCode() under the graphitron.delete-rows. namespace; downstream tooling switches on the arm rather than parsing prose. Like UpdateRowsError, it is a sibling sub-seal of AuthorError rather than a set of arms under the flat Structural, keeping the dimensional pivot one-row-per-walker. The arm set is UpdateRowsError’s minus the two arms DELETE’s shape makes meaningless: there is no `NoSetFields (DELETE has no SET clause to be empty) and no MixedCarrierKeyMembership (DELETE has no SET boundary for a composite carrier to straddle, since every admitted column is a WHERE filter). Carving DELETE off MutationInputResolver.resolveInput retired the last live @value consumer and the directive itself.

DeleteRowsError.NoUniqueKeyCoverage fires when no primary key and no unique key has its column set covered by the input’s columns and the mutation did not opt into multiRow: true; carries the table name, the input-covered columns, and every candidate key the walker considered (a table with no keys at all is the degenerate empty-candidate case, which the message points at multiRow: true). It subsumes the former table-has-no-pk rejection. DeleteRowsError.UnsupportedInputFieldShape fires for nesting fields, unbound fields without an override condition, or any non-admitted carrier; carries the field name, the classifier shape, and a reason. DeleteRowsError.OverrideConditionNotSupported fires when an input field carries @condition(override: true) (the shape is admitted at classify time, but its emit-side never landed, so the filter would silently never run); carries the field name and the directive’s source location.

MutationTableArgError is a sub-seal of AuthorError, scoped to the @mutation(table:) argument that names a DML field’s write target on the consuming field (the field-relative mechanism that replaced the deprecated @table on the input type). Like its sibling walker sub-seals it carries a stable lspCode() under the graphitron.mutation-table-arg. namespace rather than collapsing into the flat Structural. MutationTableArgError.UnsupportedVerb fires when table: is supplied on a verb that does not read it: it is wired for DELETE, INSERT, and UPDATE (the members of MutationInputResolver.TABLE_ARG_SUPPORTED_VERBS), and silently ignoring an author-written directive argument on any other verb is the green-build-wrong-intent failure the axioms forbid, so the arm rejects loudly, carrying the offending verb and the set of verbs that do accept table:.

ErrorChannelWalkerError is a sub-seal of AuthorError, scoped to the error-channel domain: the ErrorChannelWalker that resolves an outcome type’s errors-field channel onto ErrorChannel.Mapped, plus the OutcomeType classification that produces the walker’s input. Each typed arm carries the structural data its diagnostic message needs and a stable lspCode() under the graphitron.error-channel. namespace; downstream tooling switches on the arm rather than parsing prose. Like ServiceMethodCallError, it is a sibling sub-seal of AuthorError rather than a set of arms under the flat Structural, keeping the dimensional pivot one-row-per-walker. Three arms are raised by the OutcomeType classification and the rest by walk(); they share one family because they share one SDL surface (the outcome type and its errors field) and one LSP namespace.

ErrorChannelWalkerError.MultipleErrorsFields fires when a type carries more than one errors field; the binary Outcome witness has one error slot, so a type with two errors fields has no well-defined fork. Carries the outcome type name and the offending errors-field names. ErrorChannelWalkerError.NonNullableSuccessProjectionField fires when a success-projection (data) field is non-null; on the error arm that field resolves null and would raise NonNullableFieldWasNullError, bubbling the null up and dropping the sibling errors field, so success-projection fields must be nullable. Carries the outcome type name and the field name. ErrorChannelWalkerError.NonNullableErrorsField is the mirror: it fires when the errors field itself is non-null ([X!]!); on the success arm there are no errors and the field resolves null, which would raise NonNullableFieldWasNullError and drop the sibling data field, so errors fields must be nullable. Carries the outcome type name and the field name. ErrorChannelWalkerError.ChannelRuleViolation fires on a channel-level handler-rule violation (rule 7: no two VALIDATION handlers in one channel; rule 8: no duplicate match-criteria across the flattened handler list); carries the outcome type name, the errors-field name, the rule number, and a detail string, with lspCode() specialising per rule. ErrorChannelWalkerError.HandlerSourceAccessorMissing fires when an @error type’s handler source class exposes no PropertyDataFetcher-visible accessor for one of the @error type’s declared SDL fields (path and message exempt); carries the outcome type name, the @error type name, the handler class name, the missing field name, and the available accessors.

WireCoercionError is a sub-seal of AuthorError, scoped to the wire-coercion failures a scalar/enum SDL leaf hits when its consumer-declared Java type does not match what graphql-java delivers on the wire. Without the check below, an arg-classification site falls through to CallSiteExtraction.Direct and emits a raw (DeclaredType) wireValue cast that compiles cleanly and ClassCastException`s (or, for enums, `IllegalArgumentException`s) on the first request; graphql-java delivers `ID and enum values as String, Int as Integer, Float as Double, input-objects as Map, so a declared cast target of a jOOQ record, a numeric PK type, a domain class, or a width-mismatched numeric is a guaranteed runtime crash invisible at build time. The classifier now confirms the coercion output is assignable to the declared type before emitting Direct — the Direct fall-through becomes the narrow arm the predicate confirms is wire-pass-through — and a mismatch surfaces here instead. Each typed arm carries the structural data its diagnostic message needs and a stable lspCode() under the graphitron.wire-coercion. namespace; downstream tooling switches on the arm rather than parsing prose. The judgment lives at the classifier (a new WireCoercionResolver predicate consuming ScalarTypeResolver.coercionOutputType), not on ScalarTypeResolver, which stays a pure name↔type mapping.

WireCoercionError.Assignability fires when the graphql-java coercion output for a scalar SDL leaf does not equal the declared Java type (sites A-D of the audit: @service input-bean scalar fields, @service scalar args, and the non-service @condition / @externalField sites in a later slice); carries the SDL leaf type as written, the fully-qualified coercion-output class graphql-java delivers, the fully-qualified declared Java type the cast targets, and a site string. WireCoercionError.EnumConstantDivergence fires when the declared type is the enum and assignment succeeds but an SDL enum value name has no matching Java constant, so Enum.valueOf((String) …​) would throw (site E); a constant-name-set membership check on a distinct axis from Assignability, populated from the single EnumMappingResolver parity home shared with the column/arg enum path. Carries the Java enum class name, the divergent SDL value names, the full Java constant set, and a site string.

ServiceCarrierShapeError is a sub-seal of AuthorError, scoped to the @service list-payload carrier shape verdict (BuildContext.ServiceCarrierShape) computed at the @service payload seat over the triple (carrier field wrapper, @service producer return shape, payload data-field wrapper). Like its sibling walker sub-seals it carries a stable lspCode() under the graphitron.service-carrier-shape. namespace rather than collapsing into the flat Structural. The verdict only ever rejects a list-returning carrier (@service …​: [Payload]); a single carrier is always coherent and keeps its existing classification, so a coherent shape’s model and emit are byte-for-byte unchanged. Each arm carries the disagreeing arrival axes as typed Arity values plus the offending field/payload coordinate, not a reason string composed at the detection site. ServiceCarrierShapeError.ProducerArrivalMismatch fires when a list carrier’s @service producer returns a single value (arrival one) instead of a collection (arrival many): graphql-java iterates the producer’s return into the [Payload] list, so a single value yields a non-iterable source and list coercion fails at runtime. This one arm subsumes what the uncoordinated reads used to surface three ways — the a1 silent admit (bare-record producer into a single @table data field) and the two misleading record-handoff rejections (class-backed carrier, and carrier with a @table data field) — because all three are one fact: carrier arrival disagrees with producer arrival. ServiceCarrierShapeError.DataFieldArrivalConflict fires when a list carrier’s data field is itself a list produced by a flat collection: the producer’s flat list is consumed element-by-element to build the [Payload] carrier, so a single value reaches each payload and cannot also populate a list-valued data field, a per-request ClassCastException the acceptance axiom forbids. Both admitting element kinds crash this way: a @table-element data field (List<Record> producer) on the per-element key-extraction cast to Iterable, and a class-backed record-composite (RecordElement) data field (List<Composite> producer) on the source-passthrough cast of one composite to List<Composite>; filling the shape would need a List<List<…​>> producer the model has no shape for. It is a distinct axis pairing from ProducerArrivalMismatch (data-field arrival vs. a flat producer list that cannot re-nest per carrier element), so it earns its own arm; only an ID-element data field re-nests per element and stays coherent.

PivotError is a sub-seal of AuthorError, scoped to @pivot classification: every classifier decision that implies a pivot generator branch fails through one of its typed arms when violated, each with a stable lspCode() under the graphitron.pivot. namespace. The schema-shape arms fire at classify time and surface via UnclassifiedField: PivotError.NonNullSlot (a pivot slot is a filtered aggregate, null whenever no row carries its token, so a non-null slot is unsatisfiable; the field itself may be non-null because one projection record always exists per parent), PivotError.NonScalarSlot (a slot must be a single-valued scalar), PivotError.DivergentSlotType (all slots read the same value column and must share one scalar type), PivotError.VocabularyNotTextEnum and PivotError.SlotMissingFromVocabulary (the vocabulary: enum must exist and cover every slot name), PivotError.ColumnUnresolved (on: / value: must resolve on the @reference terminus; carries the candidate column list), PivotError.ValueTypeMismatch (the value column’s Java type must map to the slots' declared scalar; enforced for spec built-ins), PivotError.ListReturn (the pivot projects exactly one record per parent), PivotError.UnsupportedReferencePath (a single plain FK hop only: the batched delivery’s one-record-per-parent invariant requires the whole parent-input chain to be key-preserving, which v1 guarantees only for the single left-joinable hop), PivotError.RecordBackedParent (inline correlation needs a parent query; the message deliberately does not suggest @splitQuery, which is lint-ignored on record-backed parents), and PivotError.InvalidProjectionType (the return type must be a plain output type). The one invariant checkable on the classified leaf, two slots resolving to the same discriminator token, fires at validate time as PivotError.DuplicateSlotToken (GraphitronSchemaValidator.validatePivotSpec), carrying the token and the colliding slot names.

JooqRecordInputError is the sub-seal of AuthorError scoped to InputBeanResolver’s classify-phase resolution of a `@service parameter whose SDL input type binds a generated jOOQ TableRecord on the column axis (a CallSiteExtraction.JooqRecord), with a stable lspCode() under the graphitron.jooq-record-input. namespace. It is a sibling sub-seal rather than an arm on ServiceMethodCallError because that seal’s javadoc scopes it to ServiceMethodCallWalker and this reject is minted in the classify phase, so folding it in would break the one-producer-per-seal scoping that seal’s own sibling note asks for. JooqRecordInputError.LiveColumnCollision fires when two or more live plain @field leaves resolve to one column of the parameter’s record: the generated helper reads one value per column, so a second live writer can only silently win or lose on wire order. It carries every colliding leaf’s dotted access path with its @deprecated status, plus the column and table, so the message names the whole group and marks which members are live. The arm exists because the declared-alias shape is admitted rather than rejected: when all but at most one of the colliding leaves carry native @deprecated, the author has said "one column, several names" (the standard rename-deprecation pattern), and the fold merges the group into a single CallSiteExtraction.ColumnBinding carrying ordered read paths, live path first and the deprecated paths in reverse declaration order. That merge is also what keeps the emitter’s emitWithAgreement invariant true: at most one plain writer per column survives classification, so every overlap that reaches the value-agreement path involves a @nodeId decode. The INSERT and UPDATE mutation paths keep rejecting an all-plain overlap, on their own mechanisms (a SET map holding one value per column, and a bulk VALUES join that cannot name one derived column twice) rather than on a general "one column takes one field" rule.

InvalidSchema.DirectiveConflict carries the bare directive names (no leading @) plus the prose. The names ride as typed data so an LSP fix-it can offer "remove this directive" without scraping the prose for which one to remove.

InvalidSchema.CaseFoldCollision is the case-fold-uniqueness arm: two or more type-name stems collapse to the same identifier on case-insensitive filesystems (APFS, NTFS), which would clobber the emitted Java files. The variant carries the full case-equivalent group as a typed List<String> plus a CaseFoldCollision.Origin enum (SDL, SYNTH_CONNECTION, SYNTH_EDGE, SYNTH_PAGE_INFO) identifying which classifier arm each demoted member came from; message() switches on origin to specialise the actionable fix hint (synthesised arms point at @asConnection(connectionName: …​); SDL arms suggest a rename). Carrying the group as structured data lets an LSP fix-it offer "rename one of these" with the candidate list ready, without scraping prose.

Deferred classifies cleanly but the generator hasn’t shipped emit support for the variant yet. The arm carries a summary plus a StubKey. The stub key is a sealed sub-type with a single arm: a VariantClass names the stubbed variant class (or carries null for inline-defer sites whose rejection names a feature shape rather than a leaf class). The validator projects every Deferred through the same renderer the runtime stub uses, so a deferred message reads identically on the build log and in the stub’s exception text. The summary is the whole message: it states the fact that the feature is not yet generated and stands alone, so the render composes no roadmap-path suffix and cannot cite a transient item that would rot at a consumer with no roadmap/ directory.

The classifier-shaped emitter-assumption principle and the validator-mirrors-classifier rule both ride this contract: the validator switches on the same dispatch sets the generator does, so an unsupported classification surfaces as a typed Deferred at validate time, not as an UnsupportedOperationException at runtime. Validate is a typed-rejection projection of classify.

BuildContext.candidateHint: Levenshtein-ranked suggestions

When a name doesn’t resolve, the rejection carries the closed set of candidates the catalog had at that site; the user-visible hint sorts them by edit distance from the attempt, top five.

String hint = candidateHint(attempt, candidates);
// "; did you mean: candidate1, candidate2, candidate3"

The contract has consolidated onto two construction sites: BuildContext.candidateHint(attempt, candidates) for callers building rejection messages directly, and Rejection.unknownName(…​) (and its kind-specific factories unknownColumn, unknownTable, unknownForeignKey, …​) for callers producing the rejection through the typed sealed-result path. Both compute the same hint; the typed-result path additionally rides the attempt and candidates as structured data on the rejection so downstream tooling can offer a fix-it without parsing prose.

When adding a new existence check to the validator or builder, follow the same pattern: pass the relevant candidate list from JooqCatalog (or whatever closed set the lookup ranges over) to candidateHint, or produce the rejection through Rejection.unknownName(…​) so the candidate list rides on the typed result.

The diagnostic surface that consumers see, what each rejection class renders as, what severity it gets, what the build’s log line looks like, is documented at the diagnostics glossary; that page is the user-facing entry point for "I saw this message, what does it mean."

Drift protection

The chapter prose above enumerates Rejection’s permits: `AuthorError.UnknownName, AuthorError.Structural, AuthorError.AccessorMismatch, AuthorError.RecordBindingMultiProducer, AuthorError.TypeConflict, AuthorError.MultiProducerDomainTypeDisagreement, AuthorError.SortEnumMissingOrder, AuthorError.TenantColumnTypeDisagreement, AuthorError.NoTenantBinding, ServiceMethodCallError.MultipleDslContextSlots, ServiceMethodCallError.ParameterUnbindable, ServiceMethodCallError.InstanceHolderUnconstructible, ServiceMethodCallError.ArgumentParameterMismatch, ServiceMethodCallError.DtoSourcesUnsupported, ServiceMethodCallError.UnrecognizedSourcesType, ServiceMethodCallError.SourcesOnPkLessParent, ReflectionError.ClassNotLoaded, ReflectionError.ReturnTypeMismatch, ReflectionError.ParameterNamesMissing, ReflectionError.AmbiguousMethod, ReflectionError.SeamParameterMissing, ReflectionError.SeamCandidateAmbiguous, ReflectionError.HookNotStatic, ReflectionError.HookThrowsChecked, ReflectionError.HandleTypeMismatch, UpdateRowsError.NoUniqueKeyCoverage, UpdateRowsError.NoSetFields, UpdateRowsError.MixedCarrierKeyMembership, UpdateRowsError.NullableStraddlingReference, UpdateRowsError.UnsupportedInputFieldShape, UpdateRowsError.OverrideConditionNotSupported, UpdateRowsError.PlainColumnCollision, DeleteRowsError.NoUniqueKeyCoverage, DeleteRowsError.UnsupportedInputFieldShape, DeleteRowsError.OverrideConditionNotSupported, MutationTableArgError.UnsupportedVerb, ErrorChannelWalkerError.MultipleErrorsFields, ErrorChannelWalkerError.NonNullableSuccessProjectionField, ErrorChannelWalkerError.NonNullableErrorsField, ErrorChannelWalkerError.ChannelRuleViolation, ErrorChannelWalkerError.HandlerSourceAccessorMissing, WireCoercionError.Assignability, WireCoercionError.EnumConstantDivergence, ServiceCarrierShapeError.ProducerArrivalMismatch, ServiceCarrierShapeError.DataFieldArrivalConflict, PivotError.NonNullSlot, PivotError.NonScalarSlot, PivotError.DivergentSlotType, PivotError.VocabularyNotTextEnum, PivotError.SlotMissingFromVocabulary, PivotError.DuplicateSlotToken, PivotError.ColumnUnresolved, PivotError.ValueTypeMismatch, PivotError.ListReturn, PivotError.UnsupportedReferencePath, PivotError.RecordBackedParent, PivotError.InvalidProjectionType, JooqRecordInputError.LiveColumnCollision, InvalidSchema.DirectiveConflict, InvalidSchema.CaseFoldCollision, InvalidSchema.Structural, and Deferred. A new permit on the sealed hierarchy must land with a corresponding mention in this page; otherwise the prose silently goes stale. SealedHierarchyDocCoverageTest walks Rejection.permits() transitively and asserts each permit name appears in typed-rejection.adoc: bidirectional, tied to a closed set the compiler already exhaustivity-checks. A new permit added without a paragraph here fails the test; a permit removed without removing its mention fails too.

The sealed-Resolved pattern across the sibling resolvers is described above shape-only; per-resolver arm enumerations (e.g. LookupKeyDirectiveResolver.Resolved.{Ok, Rejected}, ServiceDirectiveResolver.Resolved.{Success.{TableBound | Result | Scalar} | ErrorsLifted | Rejected}) live as javadoc on each *DirectiveResolver.Resolved declaration. There is no single Resolved parent class to walk, and the chapter does not pin per-resolver permits.

Sealed hierarchy diagram

classDiagram
    class Rejection {
        <<sealed interface>>
        +String message()
        +Rejection prefixedWith(String)
    }
    class AuthorError {
        <<sealed interface>>
    }
    class InvalidSchema {
        <<sealed interface>>
    }
    class UnknownName {
        +String summary
        +AttemptKind attemptKind
        +String attempt
        +List~String~ candidates
    }
    class Structural_AE["AuthorError.Structural"] {
        +String reason
    }
    class AccessorMismatch {
        +String reason
    }
    class RecordBindingMultiProducer {
        +String sdlTypeName
        +List~ProducerBinding~ bindings
    }
    class DirectiveConflict {
        +List~String~ directives
        +String reason
    }
    class CaseFoldCollision {
        +List~String~ group
        +Origin origin
    }
    class Structural_IS["InvalidSchema.Structural"] {
        +String reason
    }
    class Deferred {
        +String summary
        +StubKey stubKey
    }

    Rejection <|-- AuthorError
    Rejection <|-- InvalidSchema
    Rejection <|-- Deferred
    AuthorError <|-- UnknownName
    AuthorError <|-- Structural_AE
    AuthorError <|-- AccessorMismatch
    AuthorError <|-- RecordBindingMultiProducer
    InvalidSchema <|-- DirectiveConflict
    InvalidSchema <|-- CaseFoldCollision
    InvalidSchema <|-- Structural_IS

    class LookupKeyResolved["LookupKeyDirectiveResolver.Resolved"] {
        <<sealed interface>>
    }
    class LookupKeyOk["Resolved.Ok"] {
        +ReturnTypeRef.TableBoundReturnType returnType
    }
    class LookupKeyRejected["Resolved.Rejected"] {
        +Rejection rejection
    }
    LookupKeyResolved <|-- LookupKeyOk
    LookupKeyResolved <|-- LookupKeyRejected
    LookupKeyRejected --> Rejection : carries

The overlay shows LookupKeyDirectiveResolver.Resolved as one worked example of how a resolver’s typed-result wraps a Rejection. The other twelve resolvers follow the same shape; check each *DirectiveResolver.Resolved for its specific arm set.


See also: