Skip to content

fix(compilers/openapi): place or keep every union's discriminator - #808

Open
fuad-daoud wants to merge 4 commits into
mainfrom
fix/discriminator-degraded-unions
Open

fuad-daoud wants to merge 4 commits into
mainfrom
fix/discriminator-degraded-unions

Conversation

@fuad-daoud

Copy link
Copy Markdown
Collaborator

Summary

Closes #593. Closes #595.

A union's discriminator either went somewhere it was wrong or went nowhere at all.

The rule for a degraded union with a model body

The Model keeps its discriminator only if every target it can route to is a real subtype of that model. A target counts as a subtype when it reaches the model through the allOf base the compiler elects, which is what lowers to Model.Base. The targets are the mapping values, defaultMapping, and, when no mapping entry resolves, the union branches. Otherwise the whole discriminator moves verbatim to Unmodeled["openapi:discriminator"] (degraded_lowering) with an info diagnostic.

  • Why it reads raw schemas. pass/validate judges legality from lowered Base links. But component schemas lower in source order, so a subtype declared after its base has no Base yet when the base is lowered. The compiler therefore decides from the raw schemas, mirroring what Base will be. Both declaration orders now produce identical documents.
  • One place. The rule lives in the beside closure that every degraded outcome goes through: discriminated, inline branch, oneOf beside anyOf, validation-only branches, and unresolved branches. openapi: a oneOf beside type: object with a discriminator fails validation #593's shape is one of five. The other four failed the same way, and each was probed before the fix.
  • True subtypes are unaffected. If Cat and Dog declare allOf: [{$ref: Pet}], Pet keeps its discriminator and validates clean, in either order.
  • Out of scope. openapi: keep a discriminated oneOf a union when structural keywords sit beside it #594, keeping such a union a Union, is separate.

Every place a discriminator could be dropped

The discriminator is now kept verbatim, with an info diagnostic, wherever a union lowering cannot place it:

  • the nullable collapse, at a component, property, array items, additionalProperties, parameter or response body;
  • a null-only union;
  • a union beside an enum, a const or a scalar type;
  • a $ref-site union, as an alias or on its carrier.

Unions that do place it (oneOf beside anyOf with no body, model-bodied shapes whose targets are subtypes) are unchanged. Docs: ir-design §4.8 and the §14 OpenAPI row.

Test plan

  • make gate passes after merging main (build: move to Go 1.27.2, golangci-lint v2.14.0, openapi v1.25.5 #804), with 100% statement coverage, lint (golangci-lint v2.14.0), fuzz and bench smoke.

  • Probes with morphic validate: openapi: a oneOf beside type: object with a discriminator fails validation #593's document and the inline-branch, oneOf-beside-anyOf and validation-only shapes all exit 0. On main, each exits 1 with pass/discriminator-missing-variant. openapi: a oneOf of one schema and null drops its discriminator when it collapses to a nullable reference #595's document keeps the discriminator under Unmodeled with an info diagnostic.

  • Every degraded shape is tested with pass.Validate in both declaration orders, the subtypes declared before the base. Each has a true-subtype counterpart, with a cmp.Diff between the two orders.

  • Each guard was checked by planting the defect and watching the test go red, then reverting:

    Planted defect What went red
    subtype check always true the non-subtype tables
    subtype check always false the subtype-keep tables
    gate bypassed on the other degraded paths all three shapes
    each collapse or keep site disabled TestCollapsedUnion_KeepsItsDiscriminatorAtEveryPosition and TestUnplacedDiscriminator_IsKeptAtEverySite
    the moved-discriminator diagnostic removed the validation-only case
    the moved-discriminator diagnostic duplicated the two other shapes
  • Conformance: two new specs, discriminated-union-beside-model and nullable-union-discriminator, witness tagged-unions, plus inheritance and optionality-vs-nullability respectively. Deleting the discriminator from the second spec reddens its golden. No existing golden changed.

🤖 Generated with Claude Code

https://claude.ai/code/session_016MQFAkYBVaqgSxGwe9ZJSn

fuad-daoud and others added 4 commits October 9, 2026 18:32
A discriminated union kept verbatim beside a model could leave the model
carrying a discriminator that names targets which are not its subtypes,
so a valid document failed validation. Separately, a discriminator
beside a union that collapsed to a nullable reference, a null-only
branch set, a union beside a non-model body, or a union beside a $ref
was dropped silently.

The discriminator now stays on the model only when every target it
routes to (mapping values and defaultMapping, or the union branches when
no mapping entry resolves) is a subtype along the allOf base chain. The
check reads the raw source schemas, not the registry, so a subtype
declared later cannot change the answer. Otherwise, and at every site
whose node has no field for it, the whole object is kept verbatim in
Unmodeled["openapi:discriminator"] with an info diagnostic, through one
shared helper.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Model-bodied unions kept verbatim for inline branches, oneOf beside
anyOf, validation-only branches and unresolved refs kept the
discriminator on the model without checking its targets are subtypes,
so valid documents failed pass/discriminator-missing-variant.

The gate now sits in the one closure every degraded outcome goes
through. An inline or unresolved branch counts as a non-subtype when
no mapping entry resolves.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A co-declared union reported through the validation-only keyword family
moved its discriminator to Unmodeled without any diagnostic saying so.
The beside closure now emits one info degraded-construct diagnostic at
the discriminator pointer when no other diagnostic already names the
move, so every degraded path reports it exactly once.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Picks up #804 (Go 1.27.2, golangci-lint v2.14.0, openapi v1.25.5).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016MQFAkYBVaqgSxGwe9ZJSn
@fuad-daoud fuad-daoud self-assigned this Oct 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant