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.

Sealed Resolved across the resolver siblings

Thirteen 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-Java-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
├─ 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-Java-backed accessor-resolution surface 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 R96’s 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 R190’s cross-site contextArgument type-agreement check: when two or more directive sites (@service, @tableMethod, @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 R204’s uniform-domain-return-type check: when two or more OutputField producers reach the same SDL Object return type with disagreeing DomainReturnType sealed 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 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 today-exercised case is the carrier-payload conflict (DML @mutation returning Record(table) vs @service-on-Mutation returning a typed TableRecord for the same payload SDL Object); the constraint is general across any current or future producer permit.

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 planSlug (the roadmap file basename, no extension) plus a StubKey. The stub key is itself a sealed sub-type: a VariantClass arm names the stubbed variant class (or carries null for inline-defer sites whose rejection names a feature shape); an EmitBlock arm names a typed enum value for "this shape can’t emit yet" sites inside the emitters. The validator projects every Deferred through the same renderer; the typed key lets the LSP offer "open the roadmap item" instead of parsing the path back out.

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, 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 thirteen 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
        +String planSlug
        +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: