Repository navigation
fix!: lower a union beside a body or on an allOf branch - #1688
Open
OmarAlJarrah wants to merge 20 commits into
Open
OmarAlJarrah wants to merge 20 commits into
OmarAlJarrah wants to merge 20 commits into
Conversation
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.
This was referenced Nov 10, 2026
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
JSON Schema conjoins a
oneOforanyOfwith the keywords beside it and with the other branches of anallOfit 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,constoritemsbeside 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. Atypeis 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 adiscriminatorbeside the union keeps the body, whose routes may be its subtypes. A barerequiredbeside branches that are only objects or null is a body too, sozones_targetlowers to a union of a model requiring its two names over its branch, where it had lowered toany.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
$refbranch by its target, so the body distributes across it. An outside$refto the branch pointer still finds the branch, and the variant keeps its ID in the composed namespace. Only a baretype: nullbranch still declines, since a union lifts it ontoNullablerather than conjoining it.A union on a refining
allOfbranch (#1681). A union written on a branch of anallOfwhose 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 theallOfis, and aoneOfonly 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, apatternor 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
allOfbranch (#1682). Where a schema writes no union and no discriminator of its own, itsallOfcomposes, and exactly one branch writes a union declaring shape, that union distributes as the same union written beside theallOfdoes, beside a$dynamicRefkept 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.Validaterefused 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 onmainfor{type: object, properties: {file: ...}, oneOf: [{$ref: A}, {$ref: B}]}with anencodingentry. 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 indocs/ir/payloads.md, which bumpsIRVersion.A subtype a discriminator routes to. A schema composing a discriminated base through
allOfis the subtype the base's discriminator routes to, by amappingkey or by its component name. Distributed, it would be aUnion, which is no subtype:pass.Validaterefused the mapping withpass/discriminator-missing-variant, and an implicit route left two variants sharing one tag.mainalready failed this way for$refbranches beside theallOf, and distributing inline branches and a union on anallOfbranch made the two spellingsmaincompiled cleanly fail too. Such a schema now stays aModelunder 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 anallOfbranch below any discriminated ancestor stays with its branch, as onmain.docs/compilers/openapi.md#discriminator-valuehad each variant of a distributed union carry the enclosing schema's tag; no variant carries one now.#1689, now on
main, folds anallOf's branches onto theUnionand each variant where a union beside theallOfdistributes (#1686). A union on one of theallOf's branches distributes the same way, and its composition now folds alike, so on the golden spec thedescriptionof the fiveworkers-observabilityfilters/itemsrequest-body positions lands on theUnion's docs, wheremainkeeps the whole union verbatim on a model.Out of scope
type: objectbeside a discriminated union stays a model with the union kept verbatim: openapi: keep a discriminated oneOf a union when structural keywords sit beside it #594 asks whether a discriminated union beside structure should lower as a union, which needs its own review of subtypes routed through the union.allOfwhose conjuncts admit no object still composes a model (openapi: a union beside an allOf of scalars composes a model asserting an object #1685). The golden spec writes none.mainand here (openapi: an encoding entry whose key names no declared property is dropped silently #604).Mixinwould keep both.allOfbranch below a discriminated base stays whole even at an inline position no route names, where the same union beside theallOfdistributes (openapi: a union on an allOf branch below a discriminated base stays whole where no route names the schema #1695).Test plan
Head
914592188, which mergesmainata685e0319.cloudflare/api-schemas@e1644457, compiled with--verifyonmainand the head; counts aregrep -cE '<pattern>'over stderr, the same at @ed698981, @3c4c575a and @fb54d32f against themainof their day:the body is not a model: 13 → 1, the one leftposture-api_RemediationJobAssetField/properties/value,type: stringbesidestring | number | boolean, whose string is the meet.merged in place.*branch \((anyOf|oneOf)\): 25 → 3, each a union declaring no shape (email-sending_EmailBuildertwice, the validation-onlyanyOfbesidemagic_app_config_add_single_request's distributedoneOf).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, theand/ANDandor/ORmembers ofworkers-observability_filter_node's enum and its five inline copies, now lowered where they were kept verbatim.model→unionand 12scalar→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 restatedtype: objectbeside$refbranches no longer needs. Secret properties 171 → 180,sslpost'scustom_keyamong them.filters/itemsunions above carry theirdescriptionon theUnion's docs, and their variants keep nothing of the folded branch.morphic-harnessover the golden spec with its four unresolved-security-scheme requirements removed, so no error diagnostic stops it: ok, the order-invariance oracle included.Valuelowers asValueStringdoes;Branchlowers asBesidedoes;RestatedandTtlRestatedlower as their bare spellings;Shapedistributes andTypeOnlyUnionis the union of its branches.union-restated-type,union-inline-branchesandallof-union-branchadded,allof-refinementextended,allof-oneof-cooccurrenceandunion-restated-branchesupdated to the new readings; the harness sweep reaches each.allOfwith$refbranches, beside it with inline branches, or on anallOfbranch. Routed by a mapping key,mainexits 1 on the first and 0 on the others, and5dea18eeeexits 1 on all three; routed by the subtype's name,5dea18eeeexits 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 itsallOf.typecompared without its null, inline branches rejected, the barerequiredbody off, the discriminator guard off, the shapeless guard off, the null-branch guard off,constand theitems-list guard in what branches say, the union conjunct unread,unionHeldoff, aoneOfread as ananyOf, aoneOf'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 inpartMembersandpassoff; 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,writesSiblingKeywordreading one table of the two, andbranchesSay'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,sameTextrendering before it asks where a schema is placed (a panic where no branch is the principal), a 3.0nullablebeside atyperead as 3.1's and the reverse, a barerequiredcomposing beside anitems, a cut-short branch read as object-only, a$refbranch's union counted as a branch union, a schema's owndiscriminator, anenumbeside theallOf, a branch past the flattening depth or a branch union with an unresolved$refor adiscriminatorof its own let through to distribution, an inline branch reported unresolved beside a broken$ref, aoneOfheld 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 ofmain, the fold refusal for a branch union put back, and a$dynamicRefbeside theallOfdeclining a branch union's distribution.patternor adescriptionbeside a narrower string conjunct:mainkeeps the union verbatim on a model;b0ca05189lowered each to{type: string, maxLength: 1000}keeping nothing and reporting nothing; the head keeps the union underopenapi:allOf/0with its report. The golden spec's IR is unchanged by it, as its union branches beside a narrower conjunct write a baretype.allOfwith no union, instructions retired underGOMAXPROCS=1 GOGC=off, median of 7 runs alternating:main2828.5M, the head 2834.3M (+0.21%). The cost isfindBranchUnionflattening each composingallOfto look for a union branch; a cheaper pre-scan would be a second reader of the flattening.allOfholds a union, and one beside a union, with anencodingentry: keys equalmain's and validate, where both now distribute.allOf, a union branch nested past the flattening depth: compiled in under 0.1 s and the harness passes.gofmt,go vet ./...,scripts/vet-rules.sh,go test ./internal/archtest,golangci-linton the touched packages, and each touched package's own coverage with no statement uncovered. The full gate runs in CI.Breaking
IRVersionis 0.39.0, a change of meaning at an unmoved shape. The OpenAPI compiler lowers typed a union it had kept verbatim underopenapi:allOf/<n>,openapi:oneOforopenapi:anyOf, on anallOfbranch, 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.Encodingon 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 andpass.Validaterefused them; such a consumer looking the key up among the union's own members finds none.Closes #1681, closes #1682, closes #1683, closes #661.