Skip to content

docs(emitters): hold every variant an anyOf value admits - #1329

Open
OmarAlJarrah wants to merge 9 commits into
mainfrom
docs/emitters-inclusive-unions
Open

OmarAlJarrah wants to merge 9 commits into
mainfrom
docs/emitters-inclusive-unions

Conversation

@OmarAlJarrah

@OmarAlJarrah OmarAlJarrah commented Nov 2, 2026 •

Copy link
Copy Markdown
Member

Summary

An anyOf lets 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.

  • The plan proves disjointness once (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's WireType where 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.
  • Disjointness is a fixed point. It is the least relation the rules close over, computed once over the pairs of positions the document's unions reach. A walk per union compares a pair once per path to it, which is exponential in nested required members, and caching that walk makes one union's answer depend on which union was planned first.
  • A disjoint anyOf lowers as a oneOf does. No value is admitted by two variants, so holding the one that admits it loses nothing.
  • An inclusive one holds every variant the value admits (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 whose type enum lists all 16, and every config is open, so a first match would decode every provider as the first one listed and drop its own config members.
  • Decoding is linear in the value. Which variants admit a position is decided once per type and position, and the value decoded there is shared by the variants holding it. Decoding into each variant afresh decodes a position once per path to it, exponentially many as such unions nest.
  • Encoding writes a value each set variant reads back as what it holds. Where variants write a position differently, the merge takes the first text every set variant reads back. A string variant beside a datetime_offset one therefore re-encodes a timestamp as it arrived, though Go's time.Time alone would write +00:00 as Z. A position no text satisfies is an encode error, never a silent pick.
  • A union is valid when one set variant is, as anyOf asks. 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 in pattern, and each value is admitted by both.
  • "Admits" is defined on the value and the IR, not on validation or a JSON library. A variant admits a value when its kind fits at every position, null only 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. Constraints take no part. A target's check reads this off the IR, since json/v2 decodes null into a Go string as "" without an error.
  • A null a variant admits is the union's. On an untagged union, a reference to a union with a variant admitting null lowers 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 admit null. A variant admits null through a nullable reference, the null literal, any, or a union one of whose variants does, so a null a nested union admits is the outer union's too. Under a tag or a key, null is 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.
  • Redaction reads across variants. The set variants hold one value, so a member any of them marks Secret, or types Sensitive, is redacted wherever the union is rendered.
  • Not an optional-field merge. The issue asked whether "unions never degrade to optional-field merges" rules out a member per variant. It does not: that rule forbids pooling the variants' properties, which loses which variant a member belongs to. Here each variant stays whole and named, and validation still requires one.
  • Every rule that reads one variant. A stream ending on a terminal variant now ends on an element holding one (the shared rule and Go's Recv). Inclusive joins 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.
  • Targets. Go lowers an inclusive union to a struct with a pointer per variant, and Kotlin (JVM and KMP) to a final class with a nullable property per variant. In both, generated code builds the union from one parse of the value, rather than decoding the value into each variant. The step list, both lowering tables and the shared table's wire-truth row say so, and T-29 pins the round trip, the timestamp text, validation, redaction, the linear decode and the order-independence of 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 oneOf whose 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 with patternProperties carries), #990 (a pass turning an anyOf of string consts into an enum; such a union is already disjoint by its values here).

Closes #959

Test plan

  • The rules run as a fixed point over the golden IR (spec @319dc756, morphic @3188471d): 421 of 515 unions proven disjoint, 94 inclusive, 70 of those sharing an object. The same 94 come out when the unions are planned in reverse order, and when the rules run as a per-union walk instead.
  • Planted rule removals react: without the value rule 234, without the member rule 338. Without the null rule 412, so 9 unions overlap only because two variants are nullable.
  • The counts were also checked by a second program, written separately. It agreed on every union once it counted an overridden declaration's required, as the flattening does. Without that, it reads 419: the zone settings, whose base requires id and whose members redeclare id as a one-value enum, are separated only through it.
  • Each rule probed on a compiled spec. additionalProperties: false beside patternProperties compiles to a closed model with a catch-all, and is now inclusive with a model requiring an x- member. Outer: anyOf [Inner, integer], where Inner's variants are nullable, holds null itself. Two anyOf unions 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.
  • Cost. On a chain of models each requiring two members typed as the next, the per-union walk makes 87,381 pair comparisons at depth 8 and 22,369,621 at depth 12, while the fixed point compares 2d+1 pairs (81 at depth 40). Decoding Node: anyOf [Folder, Group], both holding children: [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 one children slice.
  • Go's json/v2 re-encodes "2025-01-01T00:00:00.000+00:00" decoded into time.Time as "2025-01-01T00:00:00Z" and ".120Z" as ".12Z", and decodes null into a string as "" without an error.
  • The identity-provider example checked in the IR: one base, a 16-member closed type enum, every config open with no required member. The token-sources and created/updated unions the rules leave inclusive are the golden spec's own.
  • No golden union reaches null through a nested union or any, and no golden union is a closed model with a catch-all or names a WireType other than base64's string, so those rules change no golden count.
  • Swept docs/emitters for 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.
  • The Go sample, extracted verbatim, passes go vet.
  • CI: TestDocuments_LinksResolve and 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 reaches main. Rewording it would mean rewriting a pushed branch, which this repository forbids.

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.
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

docs/emitters: give a union that admits more than one variant (anyOf) a lowering of its own

1 participant