ID |
|
|---|---|
Status |
Ready |
Bucket |
cleanup |
Priority |
5 |
Theme |
service |
Created |
2026-07-28 |
Updated |
2026-08-06 |
Deprecate @externalField: fold the computed-field shape into @service
Proposal
Deprecate @externalField and extend @service to cover its use case. Both
directives mean "this field is resolved by external Java code" and both carry
the same ExternalCodeReference input; the split forces schema authors to
learn two directive names for one concept. The disambiguator becomes the
reflected Java method signature. Stated honestly, that is a new dispatch
mode, not an extension of an existing one: today @service projects its
return shape (table-bound / scalar / class-backed payload / polymorphic)
from the schema-declared type and uses reflection to validate against it,
so this fold introduces the first place where a Java signature selects a
field’s execution model rather than being checked against a schema-stated
one. The resulting loss of SDL visibility is the item’s central trade,
accepted deliberately and mitigated through the LSP (see Decisions). A
referenced method that takes a single jOOQ Table<>-subtype parameter and
returns a parameterised Field<X> classifies to ChildField.ComputedField
exactly as @externalField does today, while every existing @service
signature keeps its current classification unchanged, with the two narrow
cells named and accepted in Decisions: an omitted method: that now finds a
Field-returning method, and a Field-returning method the Sources-less
child path tolerated because it never read the return type.
Why the shapes do not collide
The two contracts are structurally disjoint in ServiceCatalog:
-
reflectServiceMethodaccepts parameters classified asDSLContext, argument-bound (ParamSource.Argvia name orargMapping), context-bound (contextArguments), orList/Setbatch-key containers (SourceKey.Wrap). It has noTable<?>-parameter arm. -
reflectExternalFieldrequirespublic static, exactly one parameter that accepts the parent’s generated table class (aTablesubtype, checked against the parent’sTableRefon both table identity and record type), and a return type of exactly parameterisedorg.jooq.Field.@servicehas no arm that accepts aField<X>return, but absence of an arm is not rejection: the service path reads the reflected return type only at root (the strict expected-type comparison inreflectServiceMethod, backed byvalidateRootListTableBoundReturnPairfor the list cell) and on Sources-bearing child methods (validateChildServiceReturnType, which noField<X>satisfies). A child@servicemethod with no Sources parameter never has its return type read (validateChildServiceReturnTypereturns early without aSourcedparam, andvalidateServiceRecordFielddoes not require one), so a service-parameterisedField-returning method, saypublic static Field<Boolean> m(DSLContext dsl)on a table-backed child, classifies toServiceRecordFieldtoday. Every such field is runtime-broken (the fetcher hands the jOOQFieldobject to graphql-java as the field value), so this is tolerance, not support.
So a return-type dispatch between the computed-field shape and the service
shapes is unambiguous for every signature the service path validates; the
one shape it merely tolerates (the Sources-less Field-returning cell
above) flips from silently green to a computed-contract rejection, accepted
by name in Decisions. The remaining risk is not collision but
diagnostics: a user who writes a broken signature must get a message that
names the contract they were aiming for, not a confusing rejection from the
other contract’s validator. pickMethod already rejects same-name overloads
outright, which keeps the branch deterministic.
Cross-note, no dependency edge. Landed.
roadmap/externalfield-parent-table-assignability.md has shipped: the
computed contract’s parameter check is now "accepts the parent’s generated
table class", enforced as two value comparisons against the parent’s
TableRef (table identity via denotesSameTableAs, then the parameterised
record type against recordClass). The constraint for this item is that the
new computed-contract reflection entry must keep threading the parent’s
TableRef through to that check. It is a real argument now rather than the
unread one it used to be, so dropping it fails to compile rather than
silently reopening the gap; do not narrow it back to a ClassName or a live
jOOQ Table, which the check deliberately does not need.
Design
Dispatch on the reflected return type, decided inside ServiceCatalog and
carried out of it as a sealed value. Reflection and jOOQ types stay behind
the catalog boundary: ServiceCatalog gains one entry that loads the class
and picks the method once (pickMethod already rejects same-name overloads
outright, so the pick is deterministic), reads the raw return type, and
returns a sealed contract classification. ServiceDirectiveResolver
switches on that value and never sees a java.lang.reflect.Method or an
org.jooq class.
Obtaining the dispatch key comes first, and it is not free: reading a return
type requires having picked a method, and the method-name default is itself
arm-dependent. parseExternalRef hands back a null methodName when
method: is omitted and does no defaulting; today the @externalField arm
defaults it to the GraphQL field name while the @service path passes the
null into reflectServiceMethod, which rejects with "service reference is
incomplete". The dispatch entry therefore defaults before it knows the arm;
see the omitted-method: Decision for the rule that keeps that from changing
@service behaviour. Threading matters too: reflectServiceMethod runs its
own pickMethod, so "picks the method once" means the dispatch entry passes
its pick down rather than letting the service path re-reflect. Passing the
picked method into the existing service reflection is the intended shape; a
second independent pick would be a silent double-reflection on every
@service field in the schema.
-
Raw return type
org.jooq.Fieldat a child coordinate on a@table-backed parent: the computed-field arm. The full contract (public static, exactly one parameter assignable fromorg.jooq.Table, parameterisedField<X>return, and the field-name vs real-column collision check) validates in one enforcer whose signature takes the parentTableRefand the catalog handle, so the collision check moves in with the reflection checks instead of staying behind inExternalFieldDirectiveResolverand splitting the contract across two homes. That move reorders the check on the legacy entry, where today it runs first, ahead ofparseExternalRefand reflection alike: a site whose field name collides with a column and whose method signature is broken now reports the signature. Accepted, and forced, since on the@serviceentry the collision check cannot precede dispatch (a colliding field name is not an error until the return type says the arm is the computed one). No existing test pins the old order; the collision fixture’s method is valid. Success mintsMethodRef.StaticOnlyand classifies toChildField.ComputedField. Every rejection on this arm names the computed-field contract, not the service contract. -
Any other return type: the existing service arms, byte-for-byte unchanged behaviour.
The resolver surface is a new sealed arm, not a helper call:
ServiceDirectiveResolver.Resolved gains a Computed arm, kept outside
Success (whose method() is the sealed root MethodRef) so the narrower
carrier survives in the signature. That placement is what decides which call
sites break, and only two of the four break on their own. The root query and
root mutation sites switch on the sealed root, so they stop compiling the
moment the arm lands. The class-backed child site and the table-backed child
site instead guard Rejected and ErrorsLifted with instanceof and then
narrow, switch ((ServiceDirectiveResolver.Resolved.Success) resolved), so a
Computed arm outside Success leaves both switches exhaustive, compiles
clean, and reaches an unguarded downcast that throws ClassCastException at
build time. The class-backed site is the worse of the two: its computed
rejection is an odd author error that a test suite can plausibly miss
entirely, so the cast would ship. Rewriting both switches to select on
Resolved is therefore part of this deliverable, not incidental cleanup; it
is what makes the compiler force the placement the design relies on it to
force. With that done, each coordinate places the arm deliberately: root
query, root mutation, and class-backed coordinates map it to their dedicated
rejections; the table-backed child coordinate maps it to ComputedField.
Rewriting the table-backed site’s switch also moves its join-path parse.
That site parses the service reconnect path
(ctx.parsePath(fieldDef, name, null, null), starting from the service
return type’s table) above the switch, so on the computed arm it would run
against the wrong start table before the arm is reached, and a path error
there would surface a reconnect-flavoured message on a computed field. Handle
Computed ahead of that parse and give it the parent-table-rooted call
ctx.parsePath(fieldDef, name, tableType.table().tableName(), null), which
is the same call the @externalField arm makes a few lines below.
Threading the parent table is the other consequence. The enforcer needs the
parent TableRef for the collision check, and the computed arm needs it for
the path root, but ServiceDirectiveResolver.resolve carries only
parentPkColumns and PkLessParent today. It gains a nullable parent
TableRef, and its absence is exactly the reject signal: Resolved.Computed
is minted only on the TableRef-bearing path, so root and class-backed
coordinates get their dedicated rejection from the resolver and their
Computed switch arms exist to keep the compiler honest rather than to fire.
Riding along:
ChildField.ComputedField.method narrows from MethodRef to
MethodRef.StaticOnly, which both producers already mint, binding the two
entry points by type instead of by comment.
ExternalFieldDirectiveResolver delegates to the same catalog entry and
enforcer during the migration window, so the two spellings cannot drift
apart while both are alive.
Second dispatch site: RecordBindingResolver. The producer-grounding pass
runs ahead of classification, does its own reflection, and gates on the
directive name at two entry points: groundServiceField on @service and
groundComputedField on @externalField. It cannot read the classifier’s
sealed verdict, so it needs the same return-type fork independently, and the
two entries are not interchangeable on three axes:
-
Return-element peel.
groundComputedFieldusesjooqFieldElement(Field<X>toX);groundServiceFieldusespeelReturnElement, whose container list has noorg.jooq.Fieldarm, soField<FilmRecord>grounds the raworg.jooq.Fieldas the SDL type’s backing class instead ofFilmRecord. This misgrounds silently; nothing rejects. -
Method-name default.
groundComputedFielddefaults an omittedmethod:to the GraphQL field name, matching the conventionExternalFieldDirectiveResolverdocuments and the existing pipeline fixtures rely on;groundServiceFieldreturns early whenmethod:is absent, so grounding would be skipped for a field that classifies fine. -
Carrier-only side effects.
serviceCarrierProducerArrivalMemoandgroundServicePayloadBindingare service-carrier facts and must not run on a computed field.
The fork belongs at the top of grounding: read the picked method’s raw
return type once, route org.jooq.Field to groundComputedField’s logic
(including its method-name default) and everything else to
`groundServiceField. Factor the shared reflection so the two passes cannot
disagree about which method a reference names.
Join paths: on the computed arm a @reference path parses from the parent
table exactly as @externalField does today, and the existing validator
rejection for a ComputedField carrying a join path continues to fire;
the @service reconnect path, which starts from the service return type’s
table rather than the parent, applies only to the service arms. The
ordering invariant recorded on ExternalFieldDirectiveResolver (a path
error surfaces ahead of any reflection failure) survives only on the
legacy entry; on the @service entry, dispatch must precede path parsing
because the arm is unknown until the return type is read. Revise that
javadoc to scope the invariant accordingly.
Decisions
-
Execution-model visibility moves to the LSP.
@externalFieldinlines the returnedField<X>into the parent’s SELECT projection at query-build time (via$fields, read back by result-key alias); the service shapes call the method at request time. After the fold only the Java signature tells them apart in SDL. Agreed mitigation: the hover/classification catalog already separates the two shapes (FieldClassification.ComputedvsFieldClassification.ServiceBacked), so LSP hover states the execution model explicitly, "embedded in the parent SELECT" vs "DataLoader-backed service call", and that hover text is a deliverable of this item rather than a follow-up. -
@splitQuerycomposition is rejected on the computed-field arm. The manual states that@serviceon a non-root field requires@splitQuery, but no classifier arm reads the directive on the@servicepaths today; the enforced invariant is the validator’s "a table-bound service field requires a Sources parameter". This item does not adopt the unenforced prose claim. The new arm-specific rule: aField<X>-returning method with@splitQuerypresent is an author error ("the embedded computed-field shape rides the parent SELECT; remove @splitQuery"). Named cost, accepted: the rule is conditional on a reflected fact, so it cannot live in the declarative pairwise directive-conflict table; one composition axis moves from SDL-only conflict checking to reflection-conditional checking. -
@servicewith@externalFieldon one field keeps rejecting. Both stay classification-claiming directives for the whole migration window, so co-occurrence stays a conflict, and nothing here retires it. Since the claim views landed, what names the two directives is their arms inintent_authored_field_claim: each contributes a claim, and the co-occurrence surfaces as two claims on one coordinate through theAuthoredClaimConflictsgrouping detection. Dropping the@externalFieldarm early would let@servicewin silently, sinceclassifyChildFieldOnTableTypetests@serviceahead of@externalFieldand the walk’s arm-order winner would stand with no diagnostic. The@externalFieldarm leaves the claim view at the cutover, when the directive itself goes. -
An omitted
method:defaults for the pick, then the arm decides whether the default was legal. The computed shape’s field-name convention has to reach the@serviceentry (a migrated site that omittedmethod:must keep working), but the arm is unknown until a method is picked, so the default cannot be gated on the arm. Rule: the dispatch entry defaults an absentmethod:to the GraphQL field name for the pick only. If the picked method returnsorg.jooq.Field, the computed arm accepts and the default stands. If it returns anything else, the entry restores today’s "service reference is incomplete" rejection verbatim, so no service-shaped method becomes newly reachable through an omittedmethod:. If nothing matches the field name at all, the same incomplete-reference rejection fires rather than a method-not-found from either contract: with nomethod:and no arm to attribute the failure to, the omission is the actionable diagnosis. Named consequence, accepted: on a class that happens to hold aField-returning method named after the field, an omittedmethod:now classifies where it previously rejected. That is the new capability, not a regression, and it is one of the two cells where "every existing@servicesignature keeps its current classification unchanged" needs qualification to stay true (the other is the toleratedField-returning method, next bullet). Pipeline coverage: all three cells (Field-returning default accepts, service-shaped default rejects as incomplete, no-match rejects as incomplete). -
A
Field-returning method the service path tolerated now rejects. As established under "Why the shapes do not collide", a child@servicereference with an explicitmethod:naming a service-parameterised method that returnsField<X>and takes no Sources parameter classifies toServiceRecordFieldtoday with a green validator, and is broken at request time. After the fold the return-type dispatch routes it to the computed arm, whose enforcer rejects it (the parameter list is not a singleTable<?>). Accepted: the fold converts a silently-green-but-broken schema into a build-time rejection naming the computed-field contract, which is the second qualification on "every existing@servicesignature keeps its current classification unchanged". The changelog migration note names this cell. Pipeline coverage: one row pinning the new rejection on exactly this shape. -
Inert directive parameters are author errors.
contextArgumentsand the reference’sargMappingare meaningful for service methods but can never bind on the computed-field arm (the method’s only parameter is the parent table). Reject rather than warn or ignore, matching how@externalFieldtreatsargMappingtoday. -
Static-only stays. The computed-field arm keeps the
public staticrequirement: the generated projection code calls the method during query construction, so the instance-holder shape (InstanceWithDslHolder) does not transfer. -
Root and class-backed coordinates reject. The computed-field shape needs a table-backed parent whose SELECT it can join; a
Field-returning method referenced from a root@serviceor from a class-backed parent gets a dedicated rejection naming the constraint, instead of falling through to a service-arm mismatch message. -
Deprecation is one declared fact rendered through the lint channel. The
@externalFielddefinition indirectives.graphqlsgains the docstring@deprecatedtoken with a reason naming@service.NoDeprecatedDirectiveUsageVisitoralready fires off that marker; its finding ships as a locatedBuildWarning.LintFindingcarrying aLintFixwith the two-token rewrite (@externalFieldto@service,reference:toservice:), the channel the codebase built after theIdReferenceFieldshim precedent. One fact renders into the build log and the LSP alike, and the LSP quick-fix code action is thatLintFixsurfaced by the existing machinery rather than new tooling. No separate hand-maintained classifier WARN string ships; the unlocated logger-WARN precedent is the older mechanism and is not extended. -
Migration window and tooling. Additive-then-cutover, with the cutover committed rather than discretionary: this item ships the additive half, and its Done gate files the cutover follow-up carrying a named trigger, the next major release boundary (from graphitron
11,@externalFieldkeeps its declaration so the parser does not choke and rejects at classify time with a migration message, matching the house retirement pattern). The migration rewrite is mechanical and total:@externalField(reference: {...})becomes@service(service: {...}), directive name and argument name swap, the innerExternalCodeReferencecarries over verbatim. Two tooling surfaces ship in this item: the documented one-line rewrite (changelog entry and the manual’s migration note, grep finds the sites), and the LSP quick-fix code action carried by the deprecationLintFixabove. No migration mojo ships; the quick-fix plus grep covers Sikt’s ~49 known call sites.
Deliverables
-
Classifier fold. The
ServiceCatalogdispatch entry returning the sealed contract classification; the single contract enforcer (including the column-collision check, moved in fromExternalFieldDirectiveResolver); the nullable parentTableRefonServiceDirectiveResolver.resolve; theResolved.Computedarm onServiceDirectiveResolver.Resolvedplaced at all fourFieldBuildercall sites, which includes rewriting the two(Resolved.Success)narrowing switches (class-backed child, table-backed child) to select onResolvedand moving the table-backed site’s reconnect path parse below theComputedarm; theChildField.ComputedField.methodnarrowing toMethodRef.StaticOnly; the rejection arms (root and class-backed coordinates,@splitQuerycomposition,contextArguments/argMappingpresence, the previously-tolerated Sources-lessField-returning service-shaped method, and the existing signature rejections respelled for the@serviceentry point). The return-type fork at the head ofRecordBindingResolver’s grounding pass, routing a `Field-returning reference to the computed grounding logic whichever directive spelled it. Two directive-specific strings respelled so neither misnames the spelling the author used: the join-path rejection inGraphitronSchemaValidator.validateComputedField("@externalField with a @reference path …") and the javadoc onFieldClassification.Computed("A child field using@externalField`"). Pipeline-tier coverage for the accept arm and every rejection arm, reusing `TestExternalFieldStub, plus one row asserting both spellings of the same method produce a structurally equalComputedField, which is the fold’s actual contract pinned at the tier that owns it. Where that coverage lands is an implementation choice between theGraphitronSchemaBuilderTestenum table and the spec-by-example corpus: the corpus already carries an@externalFieldclassification example and is the source of truthVariantCoverageTestreads for output-field leaves, so the both-spellings-converge row plausibly belongs there and renders into the docs for free. Decide once and keep the whole set in one place. -
Deprecation surfaces. Docstring
@deprecatedon the@externalFielddefinition with a reason naming@service; theNoDeprecatedDirectiveUsageVisitorfinding carries the two-token rewriteLintFix; lint tests (in the existing lint test family undergraphitron/src/test/.../lint/) proving an@externalFieldcall site is flagged and the fix is attached.@externalFieldclassification behaviour is otherwise unchanged. Two seams carry the whole-directive deprecation and neither fails on its own, so both must be done deliberately.DeprecationsDocCoverageTestis the bidirectional drift seam for exactly this change, and its whole-directive half iterates a hardcodedWHOLE_DIRECTIVE_DEPRECATIONSallow-list (index,record,table) rather than detecting the docstring marker:externalFieldjoins that list. Its counterpart is a row indocs/manual/reference/deprecations.adoc, in the whole-directive table alongside@tableand@index, with@servicenamed as the migration. Adding the docstring marker without both leaves the deprecation invisible to the seam built to catch it and absent from the index authors read, with a green build either way. Reassurance in the other direction:no-deprecated-directive-usageis aLintRule.Source.ENGINErule andFixtureWarningsGateTestfiltersENGINEfindings out, so the@externalFieldsites Deliverable 3 deliberately retains do not trip the sakila warnings-as-errors gate. -
Sakila proof. Migrate two existing fixtures to the
@servicespelling:Film.isEnglish(Field<Boolean>, the scalar element) and one of theInventorylift trio (Field<XRecord>, the class-backed element);filmRefis the sharpest of the three, since itsField<FilmRecord>groundsFilmCard’s backing class directly, whereas `filmCardDataandfilmCardDataMaybeMissingreachFilmRecordthrough a custom-record accessor hop that could mask a wrong-branch grounding. Both elements are needed, because the grounding fork above is only observable on the record-returning shape; a scalar-only proof passes with the pass still on the wrong branch. Keep the rest of theInventorytrio on@externalFieldto prove the migration window. Execution-tier tests updated accordingly, including one asserting both spellings coexist in a schema, and one omittingmethod:on a@service-spelled computed field so the field-name default is pinned on the new entry point too. -
LSP surfaces. Hover text for the two
FieldClassificationshapes states the execution model ("embedded in the parent SELECT" vs "DataLoader-backed service call"). Completions stop discriminating by directive name (post-fold there is no name to discriminate on). Hover and completions read different carriers and both need work; they do not share one projection. Hover switches onFieldClassification, a post-classify per-field fact. Method-name completions filter candidate methods before any field classifies, offCompletionData.Method, so the contract fact has to land there: carry the parameter’sParamSource.Tableresolution on the method entry, which is exactly the projectionExternalFieldCompletionsnames as missing and approximates today with a one-parameter-plus-Field-return shape filter. With that carried,Field-returning static methods surface at eligible@servicecoordinates off a resolved fact instead of the heuristic.CompletionData.Parameteralready declares asourcecomponent documented against theParamSourcetaxonomy includingTable, so no new carrier is needed; the work is populating it, and that is where the constraint bites.ClasspathScanneris the sole producer ofCompletionData.Method, it passesnullfor everysourcetoday, and it is deliberately parse-only: its own comment on thereturnsConditionfield records "Exact descriptor compare, not assignability: the parse-only scan resolves no type hierarchy".Table-ness is an assignability question, and a parent table class is consumer-generated under an arbitrary name, so neither thereturnsConditiontrick (a known FQN to compare against) nor a simple-name match settles it. Resolution: compare the parameter descriptor by exact FQN against the set of generated table classes the jOOQ catalog already enumerates generator-side, which keeps the scanner’s exact-descriptor discipline intact and needs no hierarchy walk. Do not widen the scanner to load and walk supertypes; that trades the invariant plus LSP-hot-path classloading for a fact the catalog can already answer. The quick-fix code action on every@externalFieldsite is the deprecationLintFixrendered through the existing lint-to-code-action machinery, rewriting to the@service(service: {...})spelling with the innerExternalCodeReferencefields verbatim.ExternalFieldCompletionsstays alive for the window. -
Docs.
service.adocgains the embedded computed-field shape (signature, worked example, constraints);externalField.adocgains a deprecation banner pointing at it;computed-fields.adocrespells its recipes to@serviceand names@externalFieldas the deprecated spelling;handle-services.adocadds the embedded shape to its response-shape overview;external-code.adocandclassifier-mental-model.adocupdated where they name the directive;deprecations.adocgains the whole-directive row per Deliverable 2. Changelog entry carries the one-line migration rewrite and names the newly-rejecting cell (aField-returning method previously tolerated as a child@service). Four sentences asserting non-root@servicerequires@splitQuerybecome wrong the moment the computed arm exists (it is a non-root@servicethat rejects@splitQuery) and must be qualified rather than left standing:service.adoc’s "On non-root fields, `@servicerequires@splitQuery`" bullet, and `handle-services.adoc’s “@serviceon a non-root field is allowed only under@splitQuery”, its "Non-root `@servicerequires@splitQuery`" gotcha bullet, and its see-also line calling `@splitQuery"the per-parent batch wrapper non-root services require".directives.graphqlsis a doc surface too, and two of its description blocks go stale:ExternalCodeReference.argMapping’s "Use on `@serviceand every@conditionsite" plus its "Structurally inert on@externalFieldand@enum(rejected at parse time)" (inert on the@servicecomputed arm as well, and rejected there post-dispatch rather than at parse time, since the parse-time gate inparseExternalRefkeys on the@externalFielddirective name), and `@service’s own docstring, whose "The signature of the method must match the inputs of the mutation or query" describes only the service shapes.
Tasks
In order:
-
Build the
ServiceCatalogdispatch entry and the single contract enforcer (column-collision check moves in), threading onepickMethodresult into both arms and applying the omitted-method:default rule; delegateExternalFieldDirectiveResolverto it; narrowChildField.ComputedField.methodtoMethodRef.StaticOnly; verify@externalFieldbehaviour is unchanged apart from the collision-check reordering noted in Design (existing tests stay green). -
Thread the nullable parent
TableRefthroughServiceDirectiveResolver.resolve; rewrite the two(Resolved.Success)narrowing switches inFieldBuilderto select onResolved(otherwise the new arm compiles clean into aClassCastException), add theResolved.Computedarm and place it at all four call sites, and give the table-backed site’s computed arm its own parent-table-rooted path parse; add theRecordBindingResolvergrounding fork; pipeline-tier accept coverage plus the structural-equality-across-spellings row. -
Add the rejection arms and their pipeline-tier coverage: root and class-backed coordinates,
@splitQuerycomposition, inert parameters, broken signatures (including the previously-green Sources-lessField-returning service-shaped cell), and the three omitted-method:cells. Respell the two directive-specific strings (validateComputedField’s join-path rejection, the `FieldClassification.Computedjavadoc). -
Add the deprecation surfaces (docstring
@deprecated, theLintFixon the visitor’s finding, theWHOLE_DIRECTIVE_DEPRECATIONSallow-list entry, thedeprecations.adocrow) and lint-test coverage. -
Migrate the
Film.isEnglishand oneInventory-trio Sakila fixture to@service; add the coexistence and method-name-default execution tests; fullmvn install -Plocal-dbgreen. -
LSP hover text, catalog-fact-driven completions (populate
CompletionData.Parameter.sourcefrom the catalog’s table-class set, no scanner hierarchy walk), and the quick-fix code action carried by theLintFix; LSP tests including a rewrite round-trip (applying the quick-fix yields a site the classifier accepts unchanged). -
Docs and changelog; draft the cutover follow-up item text (filed at the Done gate with the release-boundary trigger).
Done means
-
A
@servicereference to apublic static Field<X> m(ParentTable t)method on a table-backed child field resolves end-to-end (Sakila execution test green), with generated code identical to what@externalFieldproduces for the same method. Proven for both a scalarXand a recordX, so the grounding fork is covered. -
@servicewith@externalFieldon one field still rejects as a directive conflict, with a pipeline-tier test pinning it for the migration window. -
Every pre-existing
@serviceand@externalFieldtest stays green;@externalFieldcall sites now produce the located lint finding, in the build log and the LSP, with the rewrite fix attached. The deprecation is visible on both drift seams:WHOLE_DIRECTIVE_DEPRECATIONSnamesexternalFieldanddeprecations.adoccarries its row. -
An omitted
method:on a@service-spelled computed field resolves by field-name default, while an omittedmethod:on a service-shaped method still rejects as an incomplete reference. -
Each rejection arm has a pipeline-tier test asserting its message names the computed-field contract, and the structural-equality row proves both spellings converge on the same
ComputedField. -
LSP hover distinguishes embedded from DataLoader-backed
@servicefields, and the quick-fix rewrites an@externalFieldsite to an equivalent@servicesite the classifier accepts unchanged. -
Docs render cleanly (
mvn install -Plocal-dbwithout-P!docs); the manual nowhere recommends@externalFieldas the primary spelling. -
On Done, discard R54 (
rename-externalfield-directive) per the mutual cross-link, and file the cutover follow-up item for@externalFieldremoval with its release-boundary trigger named.
Relationship to existing items
-
Competes with R54 (
rename-externalfield-directive), which resolves the same deprecation by renaming instead (@computed/@calculatedcandidates); this item answers the successor-name question with "no new name, the successor surface is `@service`". The two items are mutually exclusive resolutions of the same problem: whichever completes first discards the other (R54 carries the matching back-pointer). R54’s open questions (deprecation-warning channel, parallel-support window length, migration tooling) are settled by the Decisions section above. -
R109 (
list-valued-external-field-multiset, Spec) authors docs and fixtures that name@externalField; if both land, whichever lands second updates the directive spelling in the other’s surfaces. -
R240 (
tablemethod-return-type-token-threading) cites the@externalFieldpath as one of two remainingMethodRef.StaticOnlymint sites; the fold moves that mint site into the@serviceresolver but does not change the carrier.
Out of scope
-
Renaming
ChildField.ComputedFieldor restructuring the classified model; both directives already converge on the same variant, so the fold is a parse/classify-surface change. -
Removing
@externalFieldoutright. It stays accepted for the migration window with a deprecation warning; removal is a follow-up gated on the window decision inherited from R54.
Fact-base note (2026-08-06)
Merging @externalField into @service is also a DDL edit once R595 ships: one intent_ relation absorbs the other (applications of the deprecated name still capture), and the claim view’s arm list, the classification-axis declaration R589 defines, changes with the merge. A ride-along consideration, not new scope.
Context and the whole-board picture: roadmap/audits/2026-08-06-fact-base-impact-sweep.md.