Skip to content

fix!: lower a union beside a body or on an allOf branch - #1688

Open
OmarAlJarrah wants to merge 20 commits into
mainfrom
fix/openapi-union-allof-branches
Open

OmarAlJarrah wants to merge 20 commits into
mainfrom
fix/openapi-union-allof-branches

Conversation

@OmarAlJarrah

@OmarAlJarrah OmarAlJarrah commented Nov 10, 2026 •

Copy link
Copy Markdown
Member

Summary

JSON Schema conjoins a oneOf or anyOf with the keywords beside it and with the other branches of an allOf it is written in. Four spellings of that conjunction lowered to IR that dropped the union or asserted a shape the source rules out. This change lowers each as the conjunction it is.

A keyword every branch already says (#1683). A type, format, enum, const or items beside a union whose every branch is held to it adds nothing, so where the branches declare shape and say every keyword that makes a body, the schema lowers as the union alone. {type: string, anyOf: [{$ref: V4}, {$ref: V6}]} lowers as {anyOf: [...]} does, where it had lowered to an unconstrained string with the union kept verbatim. A type is compared with its null, so {type: string, oneOf: [{$ref: S}, {type: 'null'}]} still keeps the body. Branches declaring no shape stay validation logic beside the body, and a discriminator beside the union keeps the body, whose routes may be its subtypes. A bare required beside branches that are only objects or null is a body too, so zones_target lowers to a union of a model requiring its two names over its branch, where it had lowered to any.

Inline branches beside a model (#661). A variant conjoins an inline branch by the node the branch lowers to at its own pointer, as it conjoins a $ref branch by its target, so the body distributes across it. An outside $ref to the branch pointer still finds the branch, and the variant keeps its ID in the composed namespace. Only a bare type: null branch still declines, since a union lifts it onto Nullable rather than conjoining it.

A union on a refining allOf branch (#1681). A union written on a branch of an allOf whose conjuncts admit no object is a conjunct admitting what its branches do, and may be the principal. The principal says the union where one of its branches says nothing the principal does not, each keyword the branch writes compared as one beside the allOf is, and a oneOf only where that branch shares no kind with another. {allOf: [{anyOf: [{type: string}, {type: number}, {type: boolean}]}, {type: string, maxLength: 1000}]} lowers to the string of at most 1000 characters and keeps nothing, where it had lowered to a model with no properties. A branch writing a bound, a pattern or an annotation says more, so {allOf: [{anyOf: [{type: string, maxLength: 5}, {type: integer}]}, {type: string, maxLength: 1000}]} keeps its union with the branch, reported.

A union on a composing allOf branch (#1682). Where a schema writes no union and no discriminator of its own, its allOf composes, and exactly one branch writes a union declaring shape, that union distributes as the same union written beside the allOf does, beside a $dynamicRef kept unexpanded too, the branch keeping nothing of it. A union declaring no shape, one of two that do, one on a branch nested past the flattening depth, and one of the declined shapes stay whole with their branch.

Form bodies that are unions. Distribution makes a form body a union in more documents. pass.Validate refused an encoding key on a union body, and the part keys read the body's flattened properties, which a union has none of, so a key fell back to <schema>/properties/<name>. That had already failed on main for {type: object, properties: {file: ...}, oneOf: [{$ref: A}, {$ref: B}]} with an encoding entry. Part keys now read a union body's variants, the first declaring a part naming it, and the validator accepts a key a variant exposes. The IR contract says so in docs/ir/payloads.md, which bumps IRVersion.

A subtype a discriminator routes to. A schema composing a discriminated base through allOf is the subtype the base's discriminator routes to, by a mapping key or by its component name. Distributed, it would be a Union, which is no subtype: pass.Validate refused the mapping with pass/discriminator-missing-variant, and an implicit route left two variants sharing one tag. main already failed this way for $ref branches beside the allOf, and distributing inline branches and a union on an allOf branch made the two spellings main compiled cleanly fail too. Such a schema now stays a Model under its base, tagged, with its union kept verbatim, a sixth shape distribution declines, so a subtype of it stays in the hierarchy; and a union on an allOf branch below any discriminated ancestor stays with its branch, as on main. docs/compilers/openapi.md#discriminator-value had each variant of a distributed union carry the enclosing schema's tag; no variant carries one now.

#1689, now on main, folds an allOf's branches onto the Union and each variant where a union beside the allOf distributes (#1686). A union on one of the allOf's branches distributes the same way, and its composition now folds alike, so on the golden spec the description of the five workers-observability filters/items request-body positions lands on the Union's docs, where main keeps the whole union verbatim on a model.

Out of scope

Test plan

Head 914592188, which merges main at a685e0319.

  • Golden spec, cloudflare/api-schemas @e1644457, compiled with --verify on main and the head; counts are grep -cE '<pattern>' over stderr, the same at @ed698981, @3c4c575a and @fb54d32f against the main of their day:
    • the body is not a model: 13 → 1, the one left posture-api_RemediationJobAssetField/properties/value, type: string beside string | number | boolean, whose string is the meet.
    • merged in place.*branch \((anyOf|oneOf)\): 25 → 3, each a union declaring no shape (email-sending_EmailBuilder twice, the validation-only anyOf beside magic_app_config_add_single_request's distributed oneOf).
    • a branch is written inline, so it names no referent: 27 → 0; declares a type that is not an object, which the composed model asserts: 8 → 0.
    • pass/name-collision: 199 → 211, the and/AND and or/OR members of workers-observability_filter_node's enum and its five inline copies, now lowered where they were kept verbatim.
    • IR: 40 model → union and 12 scalar → union, every one at a position the four issues list; the eight openapi: an allOf of a scalar union and a scalar lowers to a model with no properties #1681 needle values' empty models removed; 167 composed decorators removed, the per-branch wrappers a restated type: object beside $ref branches no longer needs. Secret properties 171 → 180, sslpost's custom_key among them.
    • The five filters/items unions above carry their description on the Union's docs, and their variants keep nothing of the folded branch.
    • No new error or warning but the name collisions; the output is byte-identical over two runs.
  • morphic-harness over the golden spec with its four unresolved-security-scheme requirements removed, so no error diagnostic stops it: ok, the order-invariance oracle included.
  • Each issue's own example compiled and read: Value lowers as ValueString does; Branch lowers as Beside does; Restated and TtlRestated lower as their bare spellings; Shape distributes and TypeOnlyUnion is the union of its branches.
  • Conformance cases union-restated-type, union-inline-branches and allof-union-branch added, allof-refinement extended, allof-oneof-cooccurrence and union-restated-branches updated to the new readings; the harness sweep reaches each.
  • A discriminated base routing to a subtype that writes a union beside its allOf with $ref branches, beside it with inline branches, or on an allOf branch. Routed by a mapping key, main exits 1 on the first and 0 on the others, and 5dea18eee exits 1 on all three; routed by the subtype's name, 5dea18eee exits 0 with two variants sharing one tag. The head validates each, the subtype a model under its base, tagged, its union kept, the key "" among the routes (TestUnion_ASubtypeADiscriminatorRoutesToStaysASubtype, allof-union-branch, discriminated-union-beside-model). An inline schema below the base that no route names still distributes beside its allOf.
  • Mutants, each reddening a named test: the restated reading off, a type compared without its null, inline branches rejected, the bare required body off, the discriminator guard off, the shapeless guard off, the null-branch guard off, const and the items-list guard in what branches say, the union conjunct unread, unionHeld off, a oneOf read as an anyOf, a oneOf's overlap ignored, the branch-union dispatch off, two branch unions allowed, a shapeless branch union counted, its keyword left as residue, the voices guard off, its set unclassified, and the union-body reads in partMembers and pass off; the routed-subtype check off at each spelling, the beside check read as the branch's, the "" key and the implicit name each read as no route, writesSiblingKeyword reading one table of the two, and branchesSay's guard for no branches off; the union branch compared by what it admits rather than keyword by keyword, unionHeld's guard for a read cut short, sameText rendering before it asks where a schema is placed (a panic where no branch is the principal), a 3.0 nullable beside a type read as 3.1's and the reverse, a bare required composing beside an items, a cut-short branch read as object-only, a $ref branch's union counted as a branch union, a schema's own discriminator, an enum beside the allOf, a branch past the flattening depth or a branch union with an unresolved $ref or a discriminator of its own let through to distribution, an inline branch reported unresolved beside a broken $ref, a oneOf held where two of its branches each hold the principal, and the principal's text unread, so a union branch written as the principal is kept; after the merge of main, the fold refusal for a branch union put back, and a $dynamicRef beside the allOf declining a branch union's distribution.
  • A union branch writing a bound, a pattern or a description beside a narrower string conjunct: main keeps the union verbatim on a model; b0ca05189 lowered each to {type: string, maxLength: 1000} keeping nothing and reporting nothing; the head keeps the union under openapi:allOf/0 with its report. The golden spec's IR is unchanged by it, as its union branches beside a narrower conjunct write a bare type.
  • Common path, on a spec of 600 components composing a base through allOf with no union, instructions retired under GOMAXPROCS=1 GOGC=off, median of 7 runs alternating: main 2828.5M, the head 2834.3M (+0.21%). The cost is findBranchUnion flattening each composing allOf to look for a union branch; a cheaper pre-scan would be a second reader of the flattening.
  • A form body whose allOf holds a union, and one beside a union, with an encoding entry: keys equal main's and validate, where both now distribute.
  • Hostile shapes: a branch union recursing through its own component, a restated union recursing through a branch, 300 union branches in one allOf, a union branch nested past the flattening depth: compiled in under 0.1 s and the harness passes.
  • Locally, at the head: gofmt, go vet ./..., scripts/vet-rules.sh, go test ./internal/archtest, golangci-lint on the touched packages, and each touched package's own coverage with no statement uncovered. The full gate runs in CI.

Breaking

IRVersion is 0.39.0, a change of meaning at an unmoved shape. The OpenAPI compiler lowers typed a union it had kept verbatim under openapi:allOf/<n>, openapi:oneOf or openapi:anyOf, on an allOf branch, beside a model with inline branches, or beside keywords every branch says, so a consumer pinned to 0.38.0 reading those keys no longer finds the union there. Content.Encoding on a body that is a union keys each part by the property of the first variant declaring it, where its keys were defined only against a body model and pass.Validate refused them; such a consumer looking the key up among the union's own members finds none.

Closes #1681, closes #1682, closes #1683, closes #661.

A oneOf or anyOf written beside a body lowered to the body alone
wherever the body was not a model, and degraded beside a model whenever
one branch was written inline. Both drop what the source says.

A `type`, `format`, `enum`, `const` or `items` that every branch
already says adds nothing to the union, so where the branches declare
shape and say every keyword that makes a body, the schema now lowers as
the union alone: `{type: string, anyOf: [{$ref: V4}, {$ref: V6}]}` as
its bare spelling, and `{type: object, oneOf: [{$ref: A}, {$ref: B}]}`
as the union of A and B. A `type` is compared with null in it, branches
declaring no shape stay validation logic beside the body, and a
discriminator beside the union keeps the body.

Beside a model, an inline branch is now conjoined by the node it lowers
to at its own pointer, as a $ref branch is by its target, so the body
distributes across it. Only a bare `type: null` branch still declines.
A bare `required` beside branches that are only objects or null is a
body too, so each variant requires those names of its branch.
An allOf whose conjuncts admit no object refines to one of them, but a
oneOf or anyOf on a branch kept the allOf a composition, so
`{allOf: [{anyOf: [{type: string}, {type: number}]}, {type: string,
maxLength: 1000}]}` lowered to a model with no properties, warned that
it asserted an object, and kept both branches verbatim.

A union on a branch is now a conjunct admitting what any of its
branches admits, and names their kinds, so it takes part in the
refinement and may be its principal. A principal lying within one
branch says the union, a oneOf's only where it shares no kind with any
other branch, so the example lowers to the string of at most 1000
characters and keeps nothing. A union that says more stays with its
branch, and one sharing no value with the rest leaves the allOf a
composition, as other disjoint conjuncts do.
JSON Schema conjoins an allOf's branches, so a oneOf or anyOf written
as a branch says what the same union written beside the allOf says.
The beside spelling distributed the composition across the union; the
branch spelling merged the branch in place and kept its union verbatim,
so `{allOf: [{anyOf: [{$ref: A}, {$ref: B}]}, {properties: {id: ...}}]}`
lowered to a record holding only `id`.

Where a schema writes no union of its own, its allOf composes, no
discriminator binds the branches, and exactly one branch writes a union
whose branches declare shape, that union now distributes as it would
beside the allOf: one composed variant per branch, minted from the
branch's pointer, the branch's own union consumed rather than kept on
every variant. A union whose branches declare no shape, one of two
that do, and one distribution declines stay whole with their branch.

A form body is now a union in more documents, and pass.Validate refused
an encoding key on a union body, which the compiler had keyed by the
body's own property, held by every variant. Part keys now read a union
body's variants, the first declaring a part naming it, and the
validator accepts a key a variant exposes.

BREAKING CHANGE: IRVersion is 0.38.0. Content.Encoding on a body that
is a union keys each part by the property of the first variant
declaring it. A consumer pinned to 0.37.0 that looks such a key up
among the union's own members finds none and renders no part.
The correction under the composition-beside-a-union finding said a union
distributes only when every branch is a $ref, as an inline branch could
hoist only at the pointer the composed variant took. Composed variants
have since taken IDs of their own, so an inline branch is conjoined by
the node at its own pointer; say so beside the reasoning it retires.
No test failed with `const` left out of the keywords a union's branches
can say, nor with a draft's `items` list read as said by a branch
writing one element schema, which would drop the tuple. Pin both, and
state in the rule and the boundaries document that a list is said by no
branch, that the union-alone reading is one of the ways a structure
beside a union is handled, and that a branch union nested past the
flattening depth stays with its branch.
A schema composing a discriminated base through allOf is that base's
subtype, and the base's discriminator routes to it by a mapping key or by
its component name. Distributing a union written beside that allOf, or on
one of its branches, made the schema a Union, which is no subtype:
pass.Validate refused the mapping with discriminator-missing-variant, and
an implicit route left two variants sharing one tag. main already failed
this way for $ref branches beside the allOf; inline branches and a union
on an allOf branch, which main kept verbatim and compiled cleanly, now
reached it too.

A union beside the allOf of a schema a route names now stays verbatim
beside the model, a sixth shape distribution declines. A union on an allOf
branch below any discriminated ancestor stays with its branch, as on main:
that reading is shared by every reader of the schema and has no position
to tell an inline schema no route names. The subtype keeps its Base and
its tag, so its own subtypes stay in the hierarchy. No distributed variant
carries a DiscriminatorValue any more.

findBranchUnion also counts the union branches before reading whether the
allOf refines, which most composing allOf schemas then never reach.
hasUnionSiblings is asked of every position lowering to a reference,
most of which write no union. For each it built the list of sibling
keywords, with a copy of the two keyword tables, and asked branchesSay,
which allocated a memo entry to answer that no branch says anything.
A schema writing no union, or a discriminator beside its union, now only
asks whether it writes any sibling keyword, which allocates nothing, and
siblingKeywords walks the two tables in place.

No lowering then asks branchesSay of a schema with no branches. Its guard
for one stays, since over no branches "every branch says it" holds of each
keyword written and a reader consuming what the branches say would drop
them; a direct call holds it, as one holds allNullUnion's.
The sentences on a union written as an allOf branch were inserted between
"one saying more is kept" and "a $ref a value set beats is kept the same
way", so "the same way" read as keeping nothing; they now follow the $ref
sentence. A test's doc comment said a keyword no branch says stays beside
the union where its case has one branch saying it, and another cited
declaresShape, which shapeKeywords replaced. Two paragraphs are reflowed
where an edit left a line of one word or one far past its neighbours.
The union a subtype a discriminator routes to writes is kept verbatim
until it can be composed by reference (#1694), and a union on an allOf
branch below a discriminated base stays whole even where no route names
the schema (#1695). The rule's status and the code stating each limit
now name the issue that lifts it.
discriminator-value said "a subtype a tag routes to", where a tag is what
a discriminator routes by, and "a branch of it" could read as a branch of
the subtype rather than of its allOf.
A union written on an allOf branch is said by the conjunct the position
lowers from where that conjunct lies within one of its branches. That was
read through what each branch admits, which leaves bounds, patterns and
annotations unread, so {type: string, maxLength: 5} read as every string.
{allOf: [{anyOf: [{type: string, maxLength: 5}, {type: integer}]},
{type: string, maxLength: 1000}]} then lowered to a string of at most
1000 characters, keeping nothing and reporting nothing, where main kept
the union. A branch now says the union only where every keyword it writes
holds of that conjunct (admission.restates), so a bound, a pattern or a
description keeps the union with its branch, reported.

Where the keywords beside the allOf decide the position, no branch is the
principal, and restates' text comparison was handed a nil schema, which
sameText rendered before asking where it was placed. It now asks first.

A direct call holds unionHeld to saying no when a read is cut short,
which no lowering reaches, since the refinement declines first.
Each guard below changed what a valid document lowers to when planted
away, and no test noticed:

- a 3.0 `nullable: true` beside a `type` adds null where the dialect
  defines it, and nothing in 3.1, when the type is compared with a branch
  admitting null;
- a bare `required` composes only alone, not beside an `items`;
- a branch whose read is cut short is no object-only branch;
- a $ref branch writing a union beside its $ref is no branch union, as the
  union conjoins with that $ref.
Removing any of these changed what a valid document lowers to, and no
test noticed:

- a schema declaring its own discriminator distributed the union on its
  allOf branch, moving the discriminator to Unmodeled;
- an enum or const beside the allOf, which decides the position, gave
  way to distribution;
- a union on a branch nested past the flattening depth distributed;
- a branch union with an unresolved $ref branch, or with a
  discriminator of its own, distributed;
- an inline branch beside an unresolved $ref branch drew a second
  unresolved-ref error;
- a oneOf on an allOf branch two of whose branches each hold the
  principal was dropped, though a value matching both fails it.
unionOf returned unionAt{holder: s, at: pointer} and nothing else, so
each caller now writes the literal. findBranchUnion's comment also names
the two conditions it checked without saying so: a discriminator of the
schema's own, and a branch past the flattening depth.
A union branch whose text is the principal's says nothing more than the
principal, so the union is said and nothing is kept. Comparing keyword
by keyword alone, a branch writing a bound or a $ref reads as saying
more. Reading the branch without the principal's text kept the union
on {allOf: [{oneOf: [{type: string, maxLength: 5}, {type: integer}]},
{type: string, maxLength: 5}]} and on the same spelling by one $ref,
and no test noticed.
Where a union beside a composing allOf distributes, the allOf's branches
fold what they say of the position onto the Union and each variant, as
main now does. A union written on one of the allOf's branches
distributes the same way, but allOfDecides still refused it, so its
other branches stayed whole on every variant and the two spellings
lowered one fact two ways. Without that refusal both fold alike.

On the golden spec this moves the description of the five
workers-observability filters/items request bodies from each variant's
Unmodeled onto the Union's docs.
A union written beside a composing allOf distributes beside a
$dynamicRef it keeps from expanding, the $dynamicRef kept verbatim on
the Union. The same union written on an allOf branch stayed whole with
its branch: findBranchUnion took its preconditions from the predicate
deciding whether an allOf may refine, which a $dynamicRef rules out for
reasons that concern refining, not distribution, and no rule names that
exception. It now asks only what distribution needs, a composing allOf
beside no discriminator of the schema's own, and so the two spellings
lower alike.

The other two conditions it carried, no $ref and no union beside the
allOf, are ones lowerSchemaBody settles before asking, a $ref site never
reaching it and a union beside the allOf lowering first, so they are
dropped and the comment says so.
The 0.39.0 entry and row named only what an encoding key on a union
body now means. The bump also covers a change docs/ir/ir-version.md
versions as one of meaning: the OpenAPI compiler no longer keeps a
union under openapi:allOf/<n>, openapi:oneOf or openapi:anyOf where it
now lowers it typed, on an allOf branch, beside a model with inline
branches, or beside keywords every branch already says. A consumer
pinned to 0.38.0 reading those keys no longer finds the union there,
which the entry and the row's meaning now say.
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