Repository navigation
docs(emitters): hold every variant an anyOf value admits - #1329
Open
OmarAlJarrah wants to merge 9 commits into
Open
OmarAlJarrah wants to merge 9 commits into
OmarAlJarrah wants to merge 9 commits into
Conversation
An anyOf lets one value satisfy several variants, and the emitter design lowered every untagged union to a closed sum that holds exactly one. Where two variants admit the same value, holding the first misnames it and drops the members only the others declare. The plan now proves, once per union, whether two variants of an anyOf can admit one value (TypePlan.Inclusive), by JSON kind, by finite value sets and by a member one model requires that the other cannot hold. A union it proves disjoint lowers as a closed sum, as a oneOf does. One it cannot is inclusive: it holds an optional member per variant, set for each variant the value admits, decodes into every candidate variant, and encodes the merge, a disagreement being an encode error. A null a nullable variant admits is held by the reference, never by a variant. Go lowers an inclusive union to a struct with a pointer per variant and Kotlin to a class with a nullable property per variant. T-29 pins the round trip of a value two open models admit.
A positional model is an array on the wire, so the kinds rule now says so and the member rule reads only models laid out by name. A stream that ends on a terminal variant now ends on an element holding one, among the several an inclusive union holds. A null literal variant now lowers as a nullable variant does, so either spelling of a null member reaches the reference. Inclusive joins the plan fields the plan-or-refine test keeps, and the docs emitter says when a value may be several variants at once.
This was referenced Nov 2, 2026
Running the rules against compiled specs showed cases where they give the wrong answer, or cost more than the value they decode: - A union validates when one set variant does, as anyOf asks. Admission leaves bounds to validation, so a variant can hold a value it does not validate. Go's Validate failed every value of a union like the API Shield token sources, whose two string variants differ only in pattern; it now passes on any set field. - Encoding writes a value each set variant reads back as what it holds, choosing among the texts the variants write. Requiring equal texts made a value just decoded refuse to encode wherever a codec normalizes, as Go's time.Time writes "+00:00" as "Z". - Decoding is linear in the value: admission is decided once per type and position, and a position's decoded value is shared by the variants holding it. Decoding into each variant afresh cost 2^d decodes for a value nested d deep under two variants that both hold the union. - Disjointness is the least relation the rules close over, computed once over the document's pairs of positions. A walk per union is exponential in nested required members, and caching it lets the union planned first decide a later one's answer. - A closed model with a catch-all no longer separates a member its catch-all governs, the kinds rule reads an encoding's WireType, and a variant admits null through a union, a decorator or any, so a null a nested union admits is the outer union's. Under a tag or a key, null stays the variant's payload. - Admission is defined on the value and the IR rather than on what a JSON library accepts, so every target sets the same variants. - A member any set variant redacts is redacted in all of them. The golden-spec counts now match the rules: 421 of 515 unions proven disjoint, 94 inclusive, 70 of those sharing an object. The earlier count read only the last declaration's required flag for a redeclared member, where the flattening counts every declaration.
The previous commit rewrapped the whole tail of the derivations item to change one phrase about Inclusive. Keeping the original wrapping and changing only the two lines that phrase spans says the same thing in a smaller diff, which also keeps the change clear of the next item.
The member rule separates two models by a member one requires, and whether a redeclared member is required decides whole unions: the zone settings' base requires id, and each setting redeclares id as its own one-value enum without required. Required counts every declaration, so those settings are disjoint. The rule now says so beside the example, rather than leaving it to the flattening's text earlier in the document.
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
An
anyOflets one value satisfy several variants. The emitter design lowered every untagged union to a closed sum holding exactly one variant, and left open what happens when two variants admit the same value. This change settles it by what the variants can share, not by how the issue's draft grouped them (scalars, models, mixed). The grouping turned out not to decide the outcome. Two string variants can be disjoint, as an enum and a literal are, and two models can share every value, as the golden spec's identity providers do.TypePlan.Inclusive,docs/emitters/plan/types.md#variants-that-can-share-a-value). Two variants are disjoint when their JSON kinds differ (a scalar's read from its encoding'sWireTypewhere it names one), when their finite value sets (closed enums, literals) do not meet, or when one model requires a member the other cannot hold. A member cannot be held when it is undeclared on a closed model with no catch-all, or declared at a disjoint position. A closed model with a catch-all holds what its patterns govern (ir: a closed object with patternProperties also carries a catch-all typed as the top type #987), so its closure alone separates nothing. The rules are sound, not complete, and the document says which pairs they leave sharing.anyOflowers as aoneOfdoes. No value is admitted by two variants, so holding the one that admits it loses nothing.docs/emitters/refine/unions.md#a-value-several-variants-admit), with an optional member per variant. Holding only the first would misname the value. The golden spec's 16 Access identity providers share one base whosetypeenum lists all 16, and everyconfigis open, so a first match would decode every provider as the first one listed and drop its ownconfigmembers.stringvariant beside adatetime_offsetone therefore re-encodes a timestamp as it arrived, though Go'stime.Timealone would write+00:00asZ. A position no text satisfies is an encode error, never a silent pick.anyOfasks. Admission leaves bounds to validation, so a variant can hold a value it does not validate: the golden spec's API Shield token sources are two strings differing only inpattern, and each value is admitted by both.nullonly where the position admits it, when closed models, closed enums, literals and scalar codecs hold it, and when every model it reaches carries its required members.Constraintstake no part. A target's check reads this off the IR, since json/v2 decodesnullinto a Gostringas""without an error.nulla variant admits is the union's. On an untagged union, a reference to a union with a variant admittingnulllowers as a nullable reference does, and a null-only variant lowers to no member. Without that,anyOf: [{type: object, nullable: true}, {type: string, nullable: true}], the golden spec's single-result envelope, would be inclusive only because both variants admitnull. A variant admitsnullthrough a nullable reference, thenullliteral,any, or a union one of whose variants does, so anulla nested union admits is the outer union's too. Under a tag or a key,nullis the named variant's payload and the variant keeps its member. Both spellings the compiler can give a null member (openapi: decide whether a mixed-type enum's null lifts out of its union #618) lower alike, so the emitter does not depend on how openapi: decide whether a mixed-type enum's null lifts out of its union #618 is settled.Secret, or typesSensitive, is redacted wherever the union is rendered.Recv).Inclusivejoins the plan fields the plan-or-refine test keeps, and the docs emitter documents a union whose value may be several variants at once. A positional model counts as an array for the kinds rule.Inclusive.Measured on the golden spec (spec @319dc756, morphic @3188471d): 515 untagged, non-exclusive unions with two or more variants. The rules prove 421 disjoint. Of the 94 left, 70 have two variants that both admit an object. Removing the value rule drops the proven count to 234, and removing the member rule drops it to 338, so each rule carries weight.
Out of scope, as the issue states: unknown variants beyond the existing fallback (#700) and marking a union open (#664). How a
oneOfwhose variants overlap decodes is left as it was.Related: #618 (the compiler's spelling of a null member), #637 (string-carried numbers, whose wire type the kinds rule reads once it is in
WireType), #987 (the catch-all a closed object withpatternPropertiescarries), #990 (a pass turning ananyOfof string consts into an enum; such a union is already disjoint by its values here).Closes #959
Test plan
required, as the flattening does. Without that, it reads 419: the zone settings, whose base requiresidand whose members redeclareidas a one-value enum, are separated only through it.additionalProperties: falsebesidepatternPropertiescompiles to a closed model with a catch-all, and is now inclusive with a model requiring anx-member.Outer: anyOf [Inner, integer], whereInner's variants are nullable, holdsnullitself. TwoanyOfunions whose model pairs depend on each other get the same answer in either order under the fixed point, where a cached per-union walk flips one.Node: anyOf [Folder, Group], both holdingchildren: [Node], into each variant under json/v2 takes 2^(d+2)−2 trials: 4,194,302 for a 302-byte value nested 20 deep. A build memoized per type and position over one parse makes about three builds per level (3,002 at depth 1,000, in 1 ms), the two variants sharing onechildrenslice."2025-01-01T00:00:00.000+00:00"decoded intotime.Timeas"2025-01-01T00:00:00Z"and".120Z"as".12Z", and decodesnullinto astringas""without an error.typeenum, everyconfigopen with no required member. The token-sources andcreated/updatedunions the rules leave inclusive are the golden spec's own.nullthrough a nested union orany, and no golden union is a closed model with a catch-all or names aWireTypeother thanbase64's string, so those rules change no golden count.docs/emittersfor rules that read "the variant" of a union value: the stream terminal rule (shared and Go) was the only one, and both now read an element.go vet.TestDocuments_LinksResolveand the emitter-doc tests over the new anchors and rows.The emitter-design PRs above this one in the stack (#1331 first) take this branch as their base, since they edit the same documents and the end of the mandatory test list. Merge this one first.
The branch's second commit subject is 71 characters, 79 with
(#1329)appended. A branch with several commits lands under the PR title, which is 64 with the number, so no subject over the cap reachesmain. Rewording it would mean rewriting a pushed branch, which this repository forbids.