ID

R675

Status

Spec

Bucket

architecture

Priority

3

Theme

interface-union

Created

2026-08-14

Updated

2026-08-19

@condition resolves its method by name alone, so per-participant overloads on a multitable filter are inexpressible

An author writing a filter input for a query that returns a multitable interface wants one predicate method per participant, since each participant is a different table with differently-named columns. The natural Java expression of that is an overload set: three methods named navn, one per participant table type. The rewrite rejects it at build time:

[author-error] input field 'navn' @condition: method 'navn' in class '…​' is overloaded (3 declarations with parameter counts [2, 2, 2]) - graphitron cannot pick one; rename or remove overloads so exactly one method named 'navn' exists

Reported against 10.0.0-RC30 as one half of a filter-input report on multitable queries. The author’s fallback is a single method taking org.jooq.Table<?> and resolving columns by name off it, which works and is what the multitable filter design intends, but gives up jOOQ’s generated column typing on a surface where the whole point of jOOQ is that the columns are typed.

Why it happens

ServiceCatalog.pickMethod is the single method-resolution point for every directive that names a Java method. It filters cls.getDeclaredMethods() by name and then judges: zero matches produce Rejection.AuthorError.UnknownName, more than one produce ReflectionError.AmbiguousMethod carrying each candidate’s parameter arity. Nothing consults the coordinate the method is being resolved for, so a name shared by several declarations is ambiguous by construction, regardless of whether the surrounding context would pick one unambiguously.

The machinery for narrowing already exists and is one argument away. pickMethod has a second form taking a SeamFilter, which narrows same-named declarations before ambiguity is judged and produces its own two rejections (SeamParameterMissing, SeamCandidateAmbiguous). Only the session-hook path passes one; the three directive reflect helpers pass null and keep exact-name behaviour. So the question is not whether narrowing is possible but what the narrowing key should be for a per-participant @condition.

What has to be decided

The multitable filter design deliberately chose the Table<?>-generic form. FieldBuilder.lowerParticipantFilters reflects the @condition method once per participant and calls it against each branch’s stage-1 alias, and its own documentation states the contract: "a Table<?>-typed first parameter serves every branch, while a concrete participant-table parameter surfaces a mismatched branch at the consumer’s javac". Per-participant overloads were never in scope; they are not an oversight so much as an unbuilt alternative.

So the item owns a decision, not a repair:

  1. Support per-participant dispatch. pickMethod gains a participant-table-typed selector for the multitable coordinate, so the overload whose table parameter matches the branch’s generated table type is chosen and the author keeps typed columns. Costs a resolution rule that varies by coordinate, and needs an answer for the partial case (overloads covering some participants but not all).

  2. Confirm Table<?> as the terminus. Then the rejection is right and the gap is that it reads as a limitation rather than a signpost: the message says "rename or remove overloads" when what the author needs to hear is "on a multitable filter, one method takes Table<?> and serves every branch". The manual documents the Table<?> form generally, but not at this coordinate, and the reporter arrived here from the legacy README’s per-type-overload pattern.

Notes carried from Backlog

  • The report claims per-type overloads work for @condition on a query field and fail only on an input field. That should not be true in the rewrite: all three directive reflect helpers route through the same name-keyed pickMethod with a null filter, so a query-field @condition with three same-named declarations should hit the same rejection. Most likely the claim is carried over from the legacy generator. The parity test below confirms it and stays as the regression pin.

  • Whichever option ships, the multitable filter documentation is in scope: the reporter called the Table<?> form "undocumented". add-custom-conditions.adoc shows the form, and global-id.adoc’s `[#multitable-filter-inputs] section even states it as load-bearing for the @nodeId override: true leaf, but no general multitable-filter documentation tells an author what to write at the reporter’s coordinate.

Reported at https://github.com/sikt-no/graphitron/issues/525 (first half; the @nodeId half is R676, nodeid-filter-per-participant-paths).


Decision: admit overload sets that agree on the binding shape; javac dispatches

Neither Backlog option ships as written. The resolution is a third shape, surfaced during the principles consultation and verified against the emitters: pickMethod stays name-keyed and coordinate-invariant, but the @condition reflect path (reflectTableMethod, the single entry for all four coordinates: argument-level, field-level, input-field, path-step) stops treating "more than one declaration" as ambiguous by itself. It judges the binding shape instead, because that is the only thing the model actually consumes from the reflection.

The load-bearing fact, verified in both @condition emission paths: the author-declared type of a Table-assignable parameter never appears in emitted code. ConditionGlueRenderer.buildGlueMethod types the glue’s table parameter from the coordinate (row.table().tableClass()); authoredExpr passes that local straight through for a reach-free predicate, and for a reach-bearing one passes the terminal hop alias, itself declared from the hop target’s generated table class. PathFragments.emitTwoArgMethodCall passes the two alias locals. (ArgCallEmitter’s `ParamSource.Table arm is not a third path: its own javadoc states every @service caller passes a null tableExpression, and the arm exists so a leaked table slot fails loudly.) So the call site the generator emits is typed by the coordinate, identical for every member of an overload set that agrees on everything except its table slots, and the consumer’s javac performs overload selection there, exactly as it already does for the single concrete-parameter form the fixtures use (Condition c(Address address, ...)).

One coordinate does consume the declared type, at classification time rather than in emission. On a path step, BuildContext.resolveConditionJoinTarget resolves the hop’s target table from the method’s second table slot whenever no declared target answers the question (a filter-path site never carries one), rejecting a Table<?> slot there as resolving nothing, and validateConditionParamTables checks concrete slot types against the hop’s origin and target. Overload admission therefore has to make those two consumers set-aware; the admission deliverable below carries the rule.

The rule: same-named declarations are admitted as one @condition target when they agree position-by-position on the binding shape: each parameter position is either Table-assignable in every declaration, or identical in name and declared type in every declaration; all declarations are static and agree on the return type. Any declaration then serves as the reflected representative (its table-slot declared types are carried but emission-inert). Declarations that disagree on the binding shape reject as ReflectionError.AmbiguousMethod, which narrows to its true meaning: not "the name is shared" but "the shared name does not denote one call shape".

What this buys, with no new resolution machinery:

  • The reporter’s overload set works, with typed columns. navn(Film, String) / navn(Forestilling, String) / navn(Arrangement, String) agree on the binding shape; each branch’s glue passes its concretely-typed stage-1 alias and javac picks the participant’s declaration.

  • Partial coverage is consumer javac, which is already the documented contract. Overloads covering two of three participants leave the third branch’s call site with no applicable declaration; that is precisely the behaviour `FieldBuilder.lowerParticipantFilters’ javadoc states for a concrete parameter today ("a concrete participant-table parameter surfaces a mismatched branch at the consumer’s javac").

  • Mixed sets and ties are javac’s most-specific rule, not a graphitron rule. A Table<?> declaration beside concrete ones acts as the fallback branch; nothing invisible-at-the-SDL is invented.

  • Resolution stays coordinate-invariant. The shape judgement is table-blind, so input-field @condition (BuildContext.buildInputFieldCondition, which has no table in scope) resolves unchanged, and no participant table threads into input classification. The path-step coordinate’s classification-time consumers of slot types become set-aware instead (the admission deliverable); reflection itself does not fork on coordinate.

Why not the Backlog options

Option 1 (participant-table-typed selector in pickMethod) threads a coordinate into the one resolution point that is deliberately coordinate-blind, restructures input classification (input types are reusable across queries and resolve with no table in scope), and has to author semantics for partial coverage, mixed sets, and assignability ties that javac already owns. The seam-filter precedent does not transfer: SeamFilter.SESSION_HOOK exists because jOOQ’s generated Routines classes force same-named overloads on the author; a @condition class is the author’s own code.

Option 2 (confirm Table<?> as the terminus) erases type information at exactly the boundary the adapter/composer principle says not to: the glue parameter is already concretely typed, so the generated side of the pair carries the type and the terminus decision would discard it, with the documented recovery being runtime rediscovery (instanceof narrowing, or a table.field(...) probe that is null on the wrong branch and throws at request time on .eq). That is the DSL-runtime surprise the pair rule exists to prevent. The Table<?> single-method form stays fully supported and documented; it is just not the only expressible form.

Deliverables

Binding-shape admission in reflectTableMethod

ServiceCatalog.reflectTableMethod receives every same-named declaration (a new pickMethod outcome or a sibling entry that returns the candidate list; pickMethod’s zero/one/many-by-name contract for the `@service, @externalField, and session-hook paths is untouched) and applies the shape rule above:

  • Agreement admits: reflect the binding shape from any representative. inferBindingsByType, the -parameters warning, and ParamSource classification are representative-invariant by construction: the shape rule already forces name-and-type identity on every non-table position, and all declarations live in one class, so the -parameters state is uniform across the set.

  • One existing check is not representative-invariant, and this item decides it. checkConditionOverrideTargets reads the names of the table-assignable parameters to reject an argMapping entry that targets the reserved slot, while the shape rule deliberately lets table slots differ in name as well as in type (navn(Film film, ...) beside navn(Forestilling forestilling, ...) is the reporter’s own shape). Both branches reject today, but with different prose: a hit on the representative’s table-slot name renders the "slot is reserved" message, a miss falls through to checkOverrideTargets and renders "references Java parameter 'film', but method …​ has parameters [forestilling, navn]", naming a declaration the author did not write the mapping against, and which of the two fires depends on getDeclaredMethods() order, which the JVM does not specify. The rule: checkConditionOverrideTargets collects the table-slot names from every admitted declaration, so each table slot in the set is reserved and the reserved-slot message fires regardless of representative. (Requiring name identity on table positions would also close it, but it rejects the reporter’s natural per-participant naming for no gain.)

  • Disagreement rejects as AmbiguousMethod.

  • The path-step coordinate reads table-slot declared types at classification time, so its two consumers become set-aware rather than representative-blind. The admitted set’s table-slot types are carried, not only the representative’s. resolveConditionJoinTarget: where a declared target answers the question the slot stays inert and overloads pass; where resolution falls to the method signature (every filter-path site), a set whose second slots all resolve the same table behaves as today, and a set that disagrees on the resolved target rejects through the existing unresolved-target path, prose naming the disagreeing declarations (the remedy is an agreeing set or, on sites that can carry one, a declared target). validateConditionParamTables becomes per-anchor applicability, the same statement handed to R647 below: at least one declaration whose slots accept the hop’s origin and target, most-specific selection left to javac.

  • Everywhere else the representative’s table-slot declared type is carried on the MethodRef.Param as today and must stay emission-inert (it already is: MethodRef’s extraction accessors throw on `ParamSource.Table, ServiceMethodCallWalker skips it, and the emitters substitute coordinate-typed expressions). If some future consumer starts reading it, the representative choice becomes visible; leave a pointer to this invariant at the admission site.

AmbiguousMethod carries the candidates as data, not prose

Per the rejection contract (rejections are facts rendered into views, never prose composed at the detection site), the message improvement is structural:

  • AmbiguousMethod gains the rendered candidate signatures (the ServiceCatalog.renderSignature form the seam arms already carry), replacing or augmenting candidateArities. Any consumer, including the LSP, can then see the overload set the author actually wrote without parsing prose.

  • The @condition-path rejection (shape disagreement) renders its own guidance from that data: which positions disagree, and that overloads may differ only in their table slots (or collapse to a single Table<?> method). If path-specific wording is needed, the blessed shape is a typed discriminant threaded as an explicit input the way SeamFilter is, with message() switching on it (the InvalidSchema.CaseFoldCollision.Origin precedent); not a nullable pre-rendered hint slot.

  • Arm identity and lspCode() (graphitron.reflect.ambiguous-method) stay stable. Drift-guards to touch: the AmbiguousMethod sentence in typed-rejection.adoc, RejectionSeverityCoverageTest.sampleFor, and the RejectionResidueDrainageTest roster if the component set changes.

  • Reach of this deliverable: the improved rejection surfaces at the argument-, field-, and input-field coordinates. The path-step coordinate discards typed reflection rejections today (BuildContext.resolveConditionRef maps any failure to ConditionResolution.Unresolved() and the caller authors a generic message); restoring its rejection fidelity is a pre-existing gap, out of scope here.

Documentation at the multitable coordinate

  • docs/manual/how-to/add-custom-conditions.adoc: a new section on filtering multitable interfaces/unions, presenting both forms. The overload-set form: one declaration per participant, differing only in the table parameter; the branch emitter calls the shared name once per participant against that branch’s concretely-typed alias, and the consumer’s javac picks the declaration; a participant with no applicable declaration fails the consumer’s compile, which is the intended guard for partial coverage. The single-method form: a Table<?> parameter serves every branch; the null-probe pattern table.field(FILM.NAVN) is typed and returns null on a branch whose table lacks the column, so the section must say in the same breath that an unguarded .eq(...) on that null throws at request time and that DSL.noCondition() is the escape for a non-matching branch; instanceof narrowing (Java 16+, safe on the consumer 17 floor) recovers the whole concrete table. The same file states the Table<?> first parameter as an invariant in three places that the new section contradicts, so they are reworded in the same commit: the "Every condition method has the same shape" lead under Write the Java method, the "The first parameter is the surrounding Table<?>" bullet below it, and "The first Java parameter is always the surrounding Table<?>`" under Constraints. The file already contradicts itself: four prose signatures in its own walkthroughs type the first parameter concretely ((City table, List<String> cityNames), `(City table, String countryId, List<String> cityNames), (Film table, String filmId), tenantScoped(City table, UUID tenantId)), so the reword reconciles the whole file, presenting those as instances of the admitted concrete form rather than leaving them as exceptions to a stated invariant. The reference page also shows a concretely-typed slot in a canonical example (iRegelverksamling(Regelverksamling rs, String regelverksamligId)), so the how-to is the coordinate out of step, not the reference.

  • docs/manual/how-to/polymorphic-types.adoc: the multitable section gains a short "Filtering" pointer to the new section (the reporter arrived at polymorphic types first and found nothing about filters there). The pointer names @condition explicitly: that page already uses the bare word "condition" throughout in the reference-path {condition:} join sense, and the pointer must not read as more of that.

  • docs/manual/reference/directives/condition.adoc: state the admission rule (overloads are legal exactly when they agree on the binding shape; disagreement rejects) and cross-reference the how-to section. Its Constraints rung on multitable @nodeId override: true leaves ends "one method cannot mean both tables", which the admission rule falsifies as stated; reconcile the wording in the same commit. Whether the route-split rejection itself lifts under per-participant overloads is R676’s territory, not this item’s.

  • docs/manual/how-to/global-id.adoc, [#multitable-filter-inputs]: this is the one existing multitable-coordinate statement of the shape, and it leans on it ("The condition method’s table parameter is Table<?>-shaped, so a split would compile and show up only as a wrong WHERE at request time"). Under admission a concretely-typed per-participant set is legal at that leaf too, so both the shape claim and the would-compile reasoning are updated in the same commit.

Tests

Pipeline tier (per docs/architecture/how-to/testing.adoc; TestConditionStub is the shared @condition fixture class), asserting typed arms, not message substrings (the same delta changes the message, so prose assertions would couple the pin to text under edit):

  • Admission: an overload set agreeing modulo table slots classifies clean at the query-field and input-field coordinates, and the reflected ConditionFilter is the same either way. Home: MultiTableFilterLoweringTest, which already carries the union Occupant = Customer | Staff schema, the per-participant input-field lowering case, and the assertLowersConditionFilterPerParticipant helper the new case asserts through. Fixture: a new pair on TestConditionStub under its own name, one declaration per participant table (Customer and Staff share first_name, so the overload set is meaningful rather than synthetic). Do not overload the existing occupantsFirstName: its Table<?> declaration is the single-method pin the argument-bearing per-participant cases assert through (the no-argument field-level case pins lifterFieldCondition), and adding declarations to it would silently convert that pin into a mixed-set case.

  • Rejection parity: a shape-disagreeing overload set produces ReflectionError.AmbiguousMethod at both coordinates (the typed-arm assertion mirrors ServiceRootFetcherPipelineTest.serviceOnOverloadedMethod_surfacesAsTypedAmbiguousMethod). This discharges the Backlog note’s claim check: the report said the query-field coordinate accepts overloads while the input-field coordinate rejects; both route through the same resolution point, and this pair is the executable form of that reading. If writing it disproves the reading, stop and reopen to Spec.

  • Dispatch proof: the reporter’s scenario end-to-end: a multitable query with a filter input whose @condition names a per-participant overload set, proving each branch calls its own declaration. The execution tier is the natural home (the emitted dispatch is javac plus runtime behaviour), and the live coordinate to extend is the AddressOccupant union (Customer | Staff) developer-@condition family: MultiTableConditionFixtures (a main-source fixture class in graphitron-sakila-service) holds the Table<?> condition methods, the @condition declarations sit in the sakila-example schema, and MultiTableFilterExecutionTest already proves per-branch firing on Query.occupantsByNamePrefix by asserting a prefix match returns rows from both branches, which an implicit equality could not. A per-participant overload set on a sibling query field, asserted the same way, is the dispatch proof. The compilation tier (the same module) additionally proves a mixed set (Table<?> fallback beside a concrete declaration) compiles.

  • Path-step set-awareness: pipeline pair for the set-aware target resolution above: an admitted set whose second table slots resolve the same table classifies on a filter-path hop as the single method does today; a set disagreeing on the resolved target rejects through the unresolved-target path. Home: the pipeline tests around reference-path {condition:} resolution, fixtures beside ReferencePathConditionFixtures.

  • Honesty note: coordinate-invariance itself is enforced by reflectTableMethod being the sole @condition resolution entry over pickMethod’s single name-filter; the pipeline pair above is a regression sample over that invariant, not the invariant’s enforcer. A structural check (no second `getDeclaredMethods() name filter in main sources) is deliberately out of scope.

Anchor definition handed to R647

R647 (condition-table-parameter-anchor-assignability) needs "the anchor table" defined before it can check anything. This item fixes that definition: the anchor is per emit-site arm and per slot (the coordinate’s table for the single-table arms, each participant’s table per branch for the multitable arm, source and target per slot for the path-step arm), and under overload admission R647’s check statement is per-anchor applicability: at least one declaration of the set whose table slot accepts that anchor, with most-specific selection left to javac. R647’s item body carries a pointer to this section (added with this spec).

Out of scope

  • Any resolution change for @service, @externalField, or the session-hook path: zero/one/many by name, seam filter where it applies, all unchanged.

  • R647’s actual assignability check (this item defines the anchor; that item builds the check).

  • A structural enforcer for the single-resolution-point invariant (named in the Tests deliverable).

  • Path-step rejection fidelity: resolveConditionRef discarding typed reflection rejections predates this item and stays; the set-aware target resolution renders through the existing unresolved-target message path.

  • The @nodeId half of issue 525 (R676, its own item, further along in the pipeline).

  • Relaying the outcome to the reporter on issue 525 happens when this ships, but the issue reply itself is not a gate for Done.

Acceptance

  • The reporter’s per-participant overload set classifies, compiles, and dispatches per branch on a multitable interface or union filter; a shape-disagreeing set rejects with the typed AmbiguousMethod at the argument-, field-, and input-field coordinates, message rendered from candidate-signature data (the path-step coordinate keeps its caller-authored message; see out of scope). A set agreeing on shape but disagreeing on the path-step target slot rejects through the unresolved-target path.

  • Non-@condition overload rejections keep their arm and code; their message may improve (signatures instead of arities) but their admission behaviour is unchanged.

  • The four documentation coordinates are reconciled with the admission rule, and the how-to presents both forms, including the null-probe failure mode and the DSL.noCondition() escape.

  • Full mvn install -Plocal-db green.