Skip to content

fix(compilers/openapi)!: lower allOf by what its conjuncts say - #1269

Merged
OmarAlJarrah merged 45 commits into
mainfrom
fix/openapi-allof-refines-a-reference
Nov 2, 2026
Merged

OmarAlJarrah merged 45 commits into
mainfrom
fix/openapi-allof-refines-a-reference

Conversation

@OmarAlJarrah

@OmarAlJarrah OmarAlJarrah commented Nov 1, 2026 •

Copy link
Copy Markdown
Member

Summary

An allOf always lowered to a Model, and an inline branch contributed only its properties and required. Two common shapes came out wrong.

  • An allOf that admits no object. {allOf: [{type: integer}, {type: integer, format: int32}]} was an empty model, so a consumer got an object with no fields for a schema meaning int32. {allOf: [{$ref: Port}], type: integer} was a model over an integer. A Model asserts an object, and the source rules one out.
  • A 3.0 document refining a reference. 3.0 reads nothing beside a $ref, so a document annotates a use by wrapping it: {allOf: [{$ref: Owner}, {nullable: true, readOnly: true, description: The owner}]}. The branch went to Unmodeled whole, leaving owner writable, non-nullable and undocumented. Spelled beside the allOf, the same keywords reached all three.

The change has three parts, specified in docs/compilers/openapi.md.

  • Refinement (allof-refinement). An allOf no conjunct can make an object is one use of the conjunct that decides it, its principal, with no Model. What each conjunct admits is read across $ref, allOf, oneOf, anyOf, type, enum and const, from the source and the bindings and never the registry, so no lowering order decides it. A narrowing enum or const wins, else the $ref, else the conjunct naming a kind that lies within the most others. Nothing is minted: each node stands at a pointer naming its schema.
  • Folding (allof-inline-residue). What an inline branch writes for a position lands where the same keyword beside the allOf lands, in a refinement and in a composition. Value bounds meet as a redeclared field's do (fix(compilers/openapi): intersect a redeclared field's bounds #1307): maxLength: 3 beside maxLength: 5 folds to 3, multipleOf: 4 beside 6 to 12. Any other keyword two conjuncts write differently, a pattern among them, folds nothing and stays whole, in every order, so no precedence between branches exists.
  • Reading through (ref-use-site). A carrier holding a refinement reads its target, and a $ref to a refinement reads through it, so a field naming {allOf: [{$ref: Id}], description: …} keeps the readOnly that only Id writes. The vendor keys feat(ir)!: type the secrecy and stability a document states #1229 promotes follow the same chain (extension-promotion): the nearest link stating a deprecation, by a keyword or a key, decides it, and any link asking for redaction asks it of the use. A $ref a value set narrows stays verbatim under openapi:allOf/<n>, still lowers what it names, and still speaks for the field's docs, deprecation, visibility and redaction, what is written beside it first. A default a $ref lends across a conjunct admitting less than it does counts only where the position takes it, one written with the set stands as a plain enum's does, and the $ref's examples stay on its target's node, as any target's do.

What a position decides, what a schema's branches say for it, what it lends a use, its rendered text and an enum's members are each read once a run, through lowering.Remembered, a per-run memo in the way TypePosition keeps its answers. Each is a function of the source and the bindings. Several readers ask for each position and every use asks of its target, and a first writer is rendered once, not at each comparison: before that, one allOf of 20,001 branches cost 559 G instructions where main cost 11 G (Cost below).

Breaking

IRVersion moves to 0.31.0, with no change of shape:

  • An allOf no conjunct can make an object is no Model, short of the cases allof-refinement keeps composing: a consumer finds a Scalar, an Enum or a reference where it found a Model.
  • A node built at an inline allOf branch takes the hint of the schema holding the allOf, where it took a union variant's, variant_N, so the symbols an emitter generates from it are renamed. TestIRVersion_PinsTheNames holds this: composed/allOf/1 in testdata/golden/openapi/hint_positions.yaml moves from variant_1 to composed.

docs/ir/version-history.md records both under 0.31.0, after #1229, #1325, #1333 and #1327 took 0.27.0 to 0.30.0. #1361 and the PRs stacked on it take 0.31.0 to 0.36.0 too, so whichever lands after another takes the next.

Decisions a reviewer should weigh

  • This departs from docs/emitter-design: four gaps the first target will hit #255 point 2, which called collapsing {allOf: [{$ref: Status}], description} a target's ergonomics and so a refine step. That still holds over an object, which stays a composition and a decorator. Over a non-object, a Model is a false claim about the wire that every consumer reading Kind sees, a position with no $ref has nothing to collapse into, and a narrowed enum has no decorator form. docs/ir/models.md#an-allof-no-object-satisfies records this with the designs rejected.
  • An inline branch's node is named as its schema's. A branch is a conjunct of the schema holding it, so a node a refinement's principal builds there is that schema's type. A composition's branch reached by a $ref into it, the one case hint_positions.yaml shows, takes the composition's hint as well, so two nodes there share composed, which an emitter tells apart as it tells any two hints apart.
  • A refinement's carrier reads promoted keys along its chain. feat(ir)!: type the secrecy and stability a document states #1229 reads a target's mapped keys at the end of a $ref only, so merged as it was, {allOf: [{$ref: T}], description: d} read T's deprecated but not its x-stability, where {$ref: T, description: d} read both. The merge reads the keys along the chain the keywords are read along, which keeps the two spellings one answer, on a carrier and on a node a narrowed $ref still speaks on, where nothing else reaches that $ref. The promoter now remembers each reading by the schema rather than its binding position, as a link inside an allOf has none.

Limits, each stated at the code and in its rule

  • A oneOf or anyOf on a branch or beside the allOf stays a composition, until openapi: distribute a union's inline branches when properties sit beside it #661 lowers those branches. On the golden spec these are all 8 remaining warnings.
  • Two $ref conjuncts stay a composition, since keeping one reference typed would drop the other.
  • Conjuncts sharing no value stay a composition with today's report, which openapi: an allOf whose conjuncts share no value draws no report #1215 widens.
  • An allOf an enum or a const beside it passes over stays verbatim, folding nothing and lowering no $ref it writes (openapi: an allOf an enum beside it passes over folds nothing and lowers no target #1309).
  • A collection bound on an inline branch stays with its branch, whatever the principal, where one beside the allOf reaches Constraints (openapi: a collection bound on an inline allOf branch beside a $ref is kept verbatim #1312): {type: array, items: …, allOf: [{maxItems: 2}]} keeps openapi:allOf/0 beside a List that has the field, as one over a $ref does, where main made either a Model.
  • Where neither of two conjuncts narrows the other, email and uuid say, the first takes the position and the other is kept with an info, so order decides there. A field redeclared the same ways does the same. A $ref element is not compared, so items: {$ref: Str} against items: {type: string, format: uuid} is such a tie.
  • Two bounds that meet but spell one number two ways, 5 and 5.0, keep the first spelling, as a redeclared field's do.
  • A format or items with no type names no kind, so it decides nothing: {allOf: [{type: integer}, {format: int32}]} lowers to integer with the branch kept, in both orders. Across the 5,935 allOf positions of golden, testdata and the corpus at 2af95505, none is spelled that way.
  • The principal takes a frame of the schema walk of its own, so inline refinements nested past 128 levels reach schema nesting exceeds 256, where plain arrays reach it at 256. main compiled such a schema only by leaving its branches unlowered. Nothing in golden or the corpus comes near: the deepest allOf lowers at a depth of 4.
  • Deciding whether an allOf over a $ref refines it reads that $ref's chain afresh at each position, up to 256 schemas, so a chain of decorators pays up to that bound at every link: 20% over main on 3,000 links (Cost).
  • Over a target that admits no object, {allOf: [{$ref: T}], K} matches 3.1's {$ref: T, K} where K is annotations, mapped vendor keys and value bounds, at every position. The two part on a structural keyword, which a $ref site still keeps verbatim (openapi: a structural keyword beside a 3.1 $ref is kept verbatim, not composed #1067), on type: [T, "null"], which conjoins here and widens there, and at a $ref naming either, which reads K here and past it there, as a $ref chain is read to its end (openapi: a use site takes its $ref chain's last annotations, skipping an alias's own #1029).

Other issues touched

Other open PRs

Not stacked. Trial-merged at 11856156, each also into main. These conflict here alone:

#1278 and #1371 conflict with main as well. The rest merge clean. #1199 widens two style checks, and internal/archtest passes on its merge at 957cd415.

Golden spec

cloudflare/api-schemas @319dc756, main @117d29e7, this branch @5cfd7105, whose counts below 11856156 reproduces exactly.

go run ./cmd/morphic compile --verify openapi.json > ir.json 2> diags.txt
awk '{print $1,$2}' diags.txt | sort | uniq -c | sort -rn
jq -c '[.types[]|.kind]|group_by(.)|map({(.[0]):length})|add' ir.json
jq -c '[.. | objects | select(has("unmodeled")) | .unmodeled // {} | keys[] | select(test("^openapi:allOf/"))] | length' ir.json
grep -c 'of kind "model", which has no home for type' diags.txt

On main:

   1486 info openapi/degraded-construct
    194 info openapi/inferred-nullable
    121 warning openapi/degraded-construct
     34 info openapi/composition-lowering
     10 info openapi/validation-only-keyword
      9 error openapi/unresolved-security-scheme
      5 warning openapi/reserved-header-name
      3 warning openapi/inapplicable-location-keyword
{"enum":8314,"list":9822,"literal":195,"map":508,"model":18353,"primitive":16,"scalar":3515,"union":974}
201
39

On this branch:

   1419 info openapi/degraded-construct
    198 info openapi/inferred-nullable
     34 info openapi/composition-lowering
     13 info openapi/validation-only-keyword
      9 error openapi/unresolved-security-scheme
      8 warning openapi/degraded-construct
      5 warning openapi/reserved-header-name
      3 warning openapi/inapplicable-location-keyword
{"enum":8334,"list":9822,"literal":195,"map":508,"model":17836,"primitive":16,"scalar":3549,"union":974}
68
0

The last two lines are the openapi:allOf/<n> entries and the no home for type reports on a model. The 8 warnings left all sit beside an anyOf on a branch. Both builds exit 1 on the same 9 security requirements naming schemes the spec does not declare. 227 properties list the extension-promotion marker here, against 211 on main: each of the 16 is a refinement whose deprecation message a mapped key states, beside it or on its target, now on the field as its $ref spelling's would be, where main left it on a decorator node. email-security_AllowPolicy.last_modified is one. Compiling the spec costs 84.34 G instructions here and 83.47 G on main, +1.0% with overlapping ranges (Cost).

The hardest sites, read in the IR:

  • access_target_criteria_base.port names access_port, with no node and no diagnostic. On main it was a model holding openapi:type.
  • access_end_user_props.type is a one-member Enum, with "The application type." from access_type on the field and that $ref under openapi:allOf/0.
  • pages_project.canonical_deployment stays a model decorator, now nullable with nullable-widening, read-only and described.
  • waf-product-api-bundle_custom-scan.id is read-only, from a readOnly two hops away on a component refinement.

Cost

Instructions retired by the compile stage alone, openapi.New().Compile in a benchmark run once a process under /usr/bin/time -l with GOMAXPROCS=1 GOGC=off, the median and range of 3 runs alternating, main @117d29e7 against this branch @5cfd7105. The collector's work varies with machine load, which had widened runs of the CLI to a 9% range, so it is left out.

shape main this branch
the golden spec 83.47 G [82.99–84.41] 84.34 G [83.51–84.43] +1.0%
one allOf of 20,001 branches, the first describing 400 KB 7.78 G 8.12 G +4.4%
3,000 refinements of a shared array whose items carry a 300 KB description 5.61 G 5.74 G +2.2%
6,000 uses of a decorator of 6,000 agreeing branches 18.98 G 19.70 G +3.8%
the same at 400, 800, 1,600 0.53, 1.06, 2.49 G 0.58, 1.16, 2.68 G +9%, +9%, +8%
a 3,000-link chain of decorators over an object, 3,000 uses 6.14 G 7.38 G +20%
3,000 uses of a refinement whose branch writes a 150 KB description 1.53 G 1.55 G +0.8%
3,000 refinements narrowing one 3,000-member enum 11.12 G 11.46 G +3.0%

Each range but the golden spec's is within 0.02 G of its median. Before the memo, at cb1af3ae, the first three shapes cost 559.5 G, 63.6 G and 472.8 G through the CLI and the uses 2.6, 9.1 and 35.0 G, growing with their square. Those runs were the CLI's on a loaded machine, so only the size of the gap reads off them.

The chain is the one shape more than 10% above main, for the reason its limit above states; the rest are within 9%. The enum row cost 17.8% more through the CLI at fbebf329, until 63fd362e: electing the narrowing compared the target's value set with itself at each position, a walk of all its members. What remains of its cost on both builds is #1369. Through the CLI, the 150 KB row costs 10.37 G here and 2.02 G on main (at fbebf329 and 7f849127, 5 runs alternating): that is the output, as each of 3,000 fields now holds the description, 452 MB of IR, which main writes too for the same description beside a plain type.

Test plan

On 18dc7539 unless named.

  • At 11856156, the flat spelling against main: no comparison where main matches it and this branch does not, over the 748 pairs below on 3.0 and 3.1, and over 118 one- and two-conjunct cases at a query parameter, a request body, a response header, a map value and a union variant on each. Each difference left at those positions is an inline enum or array principal keeping its node while a sibling's keyword lands on the carrier or a decorator alias, or a 3.0 nullable with no type beside it, read in under the widening policy and marked inferred. The 984 specs pass morphic-harness but two 3.0 ones writing readOnly with writeOnly, which the validator refuses.
  • At fb0fa6a7, two oracles against the flat spelling. One compiles {allOf: [K]} against K for 48 keyword sets at a property, an items and a component, on 3.0 and 3.1; the other {allOf: [K1, K2]} and {K1, allOf: [K2]} against {K1, K2} for the 748 ordered pairs of 28 keyword sets sharing no keyword, at a property and an items. Each difference is one a rule states: a format or items with no type deciding nothing, an inline principal keeping its own node, where a sibling branch's keywords land on the carrier or an alias and a component is a named alias over it, an enum beside the allOf passed over (openapi: an allOf an enum beside it passes over folds nothing and lowers no target #1309), conjuncts sharing no value (openapi: an allOf whose conjuncts share no value draws no report #1215), and a collection bound on a branch, whose stated limit 11856156 widens from a $ref to every principal. On 3.1, main differs on 47 of the 48 one-branch sets. All 844 specs pass morphic-harness but a 3.0 type: null, which the validator refuses. 38 defects planted across the admission read, the election, the voices, a use's chain, the annotation reader and the parameter readers each fail a test.
  • CI's gate on 11856156, which holds main @bc76c006: both jobs pass, run 41477233669.
  • The merge of main @bc76c006 (feat(compilers/openapi): read each document's schema identifiers #1227), which resolves a $ref against each document's schema identifiers: clean, with no version of its own. sweep.py over testdata, the reference corpus and the golden spec shows feat(compilers/openapi): read each document's schema identifiers #1227 changing one spec on main and the same one here, its own identifiers_in_another_document, with the same IR and diagnostics on both. A refinement over a $ref an $id resolves reads that target as its $ref spelling does, and the keyword-against-key and redaction grids below find no difference on the merge either.
  • 120 cases of 15 spellings at 8 positions, each compiled with its target deprecated by deprecated: true and by x-stability: deprecated, and again for format: password against x-sensitive: true: no deprecation, Sensitive or Secret differs on any type, property, parameter or header, here or on main, where the build before 2df2b53b differs in all 120. Their 480 specs pass morphic-harness.
  • 2df2b53b: a node a $ref still speaks on, narrowed by a value set or passed over by an enum beside its allOf, takes that $ref's promoted keys as it takes its keyword. TestPromotion_ALostRefLendsTheNodeItSpeaksOn pins both spellings, and a declaration given no referent again fails it, against a control run that passes. A sweep of 2,183 specs from 8e600020 finds no difference, as none narrows a target a key deprecates.
  • The merges of main @02c972b6 (feat(ir)!: type the secrecy and stability a document states #1229, fix!: type the local time formats and keep time's offset #1325), @117d29e7 (fix(compilers/openapi): keep unmounted path-item docs and params #1322, feat!: follow references into OpenAPI 3.2 components/mediaTypes #1333) and @6996ce45 (fix(compilers/openapi)!: scope and build path item entries once #1327): TestIRVersion_PinsTheEncodedShape and TestIRVersion_PinsTheNames at 0.31.0, the conformance goldens, TestLoweringSpec, the census and the promotion tests. secret-format was regenerated, and differs from main's by this branch's cases and from this branch's by the parameter's Secret. Promotion through a refinement is pinned by TestPromotion_ARefinementLendsWhatItRefines and by TestPromotion_ADecoratorMatchesTheRefItStandsFor, which now holds a refinement's carriers to the $ref spelling's and keeps the fold comparison over an object. TestParameter_ABranchSpeaksAsItsSchemaDoes now holds a branch's format: password to the parameter's Secret, which feat(ir)!: type the secrecy and stability a document states #1229 gave it a home in. Six planted defects in KeysLentTo each fail the row written for them, against a control run that passes: reading the first link only, a keyword not deciding, a branch's keyword ignored, redaction read off the deciding link alone, the farthest link deciding, and a position owning a node lending its carrier.
  • The election fix 63fd362e: sweep.py from the merge before it over testdata, the reference corpus and the golden spec, 2,183 specs, finds no difference in exit code, IR or diagnostics. TestElect_WalksOnlyTheSetItNarrows fails on each of 3 planted defects: the $ref compared with itself, a conjunct with itself, and a larger set walked for a member a smaller one lacks.
  • Seven generated shapes at three sizes each, counted in executed compiler statements at fbebf329 against 7f849127: none grows faster than its size on either build, but for the enum term 63fd362e removes and the chains within the read limit.
  • openapi: an allOf of one $ref and an annotation-only schema keeps none of the annotations typed #660's and openapi: an allOf of primitive branches lowers to an empty model #1043's specs compiled literally: owner nullable, read-only and described, identically to the same keywords beside the allOf; zone_id keeps its description and maxLength; T a named int32 with no diagnostic; disjoint branches still warn at each branch.
  • What main gained since 2af95505 met this branch's readers in three places, each settled in the merges and pinned by a test. A type: null, which reads as the keyword left out (fix(compilers/openapi): keep a keyword at every schema outcome #1217), had admitted nothing and kept {allOf: [{type: null}]} a Model, which TestCompile_ANullKeywordLowersAsTheKeywordLeftOut reddened on; a description: null on one branch disagreed with another branch's description, now TestVoices_ANullKeywordWritesNothing; and fix(compilers/openapi)!: type a collection bound beside a $ref #1302's collection bound beside a reference reaches a refinement's alias or carrier, which TestCollectionBound_BesideARefinementReachesWhatThePositionOwns pins.
  • A nullable: false branch widened a position whose only true sat beside a $ref branch, which no fold compares: {type: string, allOf: [{$ref: Str, nullable: true}, {nullable: false, description: d}]} was a nullable reference. Fixed in c917e4e6, with a test against the same allOf without the false branch.
  • sweep.py over testdata and the reference corpus, with each commit since the merges against its parent: the cost commit 38f35b54 against cb1af3ae, 2,179 specs, 0 differ, IR bytes equal; the bound meet against 74054539, 2,180 specs, 4 differ, each a pair of bounds folding to the tighter value with its two branches no longer kept. The golden spec's IR is byte-identical across both and across c917e4e6.
  • 28 planted defects in the merges' resolutions and the commits after them, each against the test meant to catch it: all killed. One survived first, a branch that does not fold marking what it writes as lent, and TestLendingBranches_LendsEachEntryOnce now covers it. The byte-count tests the cost commit adds pass 20 runs under -race -covermode=atomic.
  • The bound meet against main: 3.0's boolean and 3.1's numeric exclusive bounds, alone, together and against a bound beside the allOf, each lowering to the conjunction; a lone 3.0 exclusiveMinimum: true on a branch, reported there and leaving the minimum beside the allOf inclusive; a refinement's branches meeting each other and a bound beside the allOf on the field, while the target keeps its own on its node; and multipleOf: 7e10000000 beside 3, which meets to 210000e9999996 at once, as a redeclared field's does on main.
  • At 909ddee0, an ordered-pair grid of 31 conjunct spellings on 3.0 and 30 on 3.1: each pair as an allOf in both branch orders, with a use of it, compared with branch indices mapped to their content. Of 900 pairs, the one that differs is two $refs, whose Mixins keep source order; with agreement planted to let the first writer win, 7 more differ on each version. Multipart parts and parameters written as refinements come out as their plain spellings do, where main kept them as models, and a refinement in a second document reads that document's $refs, with its reports there. The 29 specs behind these probes pass morphic-harness.
  • At 2af95505, before main's fix(compilers/openapi): keep a keyword at every schema outcome #1217, feat(ir)!: settle doc references, promotion records and names #1219, fix!: keep a security requirement naming no scheme #1226, fix(compilers/openapi)!: type a collection bound beside a $ref #1302, feat(engine): add an opt-in content-addressed IR cache #1304 and fix(compilers/openapi): intersect a redeclared field's bounds #1307 were merged in: an ordered-pair grid of 50 conjunct spellings on 3.0 and 60 on 3.1 in both orders with --verify, whose ties are each reported in both orders; 810 cases of 18 annotations and value bounds over 5 targets at every position against their 3.1 $ref spelling; 54,912 keyword-presence pairs; a per-carrier scan of 137,467 and 61,173 properties and parameters with a planted control, none lost; and 153 planted defects, 148 killed and 5 equivalent. TestRefinement_DecidedByTheSetInEveryOrder, TestNullableWidening_ABranchWidensTheAllOfHoldingIt and the tests their rules name hold what those taught.

Closes #660
Closes #1043

A use site read its annotations from two schemas, the one written there
and the schema its $ref chain ends on, each reader taking the pair apart
on its own. Readers now take a chain of schemas, nearest first, so a
position can name more than one source without each reader growing an
argument. Site gains Voices, inline schemas read at their own pointers
beside the schema written at the position. Nothing yet supplies one, so
no output changes.
An allOf always lowered to a Model, and an inline branch contributed only
its properties and required list. Two consequences reached consumers. An
allOf admitting no object, `{allOf: [{type: integer}, {type: integer,
format: int32}]}`, became an empty model, so the IR asserted an object the
source rules out. And an OpenAPI 3.0 document refining a reference, the
only way 3.0 can annotate one, lost its annotations: the branch of
`{allOf: [{$ref: Owner}, {nullable: true, readOnly: true}]}` went to
Unmodeled verbatim, leaving a field typed as writable and non-nullable.

An allOf no conjunct can make an object is now a refinement: one use of
the conjunct deciding it, reached through a reading of what each conjunct
admits, across $ref, allOf, oneOf, anyOf, type, enum and const. It reads
the source and never the registry, so no lowering order decides it, and a
position's many readers share one reading a run. A cycle, a spent budget,
a false branch or a union on a branch stays the composition it was, which
loses nothing.

What an inline branch writes for a position now folds there, in a
refinement and in a composition, each keyword landing where the same
keyword beside the allOf lands. A branch disagreeing with any conjunct on
one folds nothing and stays whole, in every order, so no precedence
between branches exists. The use reads through what it refines, so a
field naming a component refinement keeps a readOnly on its second hop.

A $ref a value set narrows stays verbatim and still speaks for the field,
its default only where the set takes it. The IR's shape does not change.
Three keywords a refinement's conjuncts wrote reached nothing, with no
report, where the plain spelling of the same schema keeps them.

A `nullable: true` on a branch was consumed as folding, but it widened
only the allOf: a `type` beside the allOf forbids null, and the conjunction
of the two left the position non-nullable while the branch kept nothing.
It now widens the whole schema, as the keyword beside the allOf does.

A default written with a value set was judged by that set, which applies
only past the narrowing: `{allOf: [{$ref: Mode}, {enum: [a], default: x}]}`
lost it, and through a $ref to such a component the target's default won
over the nearer one. The filter now starts at the $ref the set narrows.

A parameter's readOnly and writeOnly were read off its schema and its
voices, so a principal owning a node, which holds neither, dropped them.
The rule said a `$ref` whose target admits an object makes an allOf a
composition, but `{allOf: [{$ref: T}, {type: string}]}` over a T that may be
either refines, as a Model there would be false. It also said any composition
saying more than its `$ref` lends a carrier nothing, though a bound beside the
`$ref` lends as the 3.1 spelling does, and listed the ways the two spellings
part without the third: a `$ref` naming them reads each differently.

models.md still gave `allOf: [{$ref: SomeScalar}]` as a Base over a Scalar,
which is the shape this rule replaces. Rewrap what ran past the column.
The report on a branch with residue said only its properties and required
list compose, which stopped being true once what a branch writes for the
position folds: `{description: d, additionalProperties: false, ...}` folds
the description and names `additionalProperties`, under words claiming
neither happened. It now says nothing reads the rest of the branch.
The reading of what a schema admits compared values as rendered text, so
`{allOf: [{$ref: Level}, {enum: [1.0]}]}` over an enum of 1 and 2 read as
sharing nothing and stayed a model, a default of 1.0 was refused by an enum
of 1, and which of two lists narrowed the other turned on their spelling.
JSON Schema holds 1 and 1.0 to be one value. The enum's own entry key already
says so, numbers by magnitude and objects whatever their member order, so the
reading now compares by it.
The emitter tables said a Model over a non-object is only a Base that may be
an object or conjuncts no value satisfies, but a conjunct writing what only a
Model holds composes too: `{allOf: [{$ref: Str}], required: [a]}` is a Model
over a string. models.md said every conjunct stays reconstructible, which a
restated `type` is not: it is consumed, saying only what the position does.
A member was read twice, as a value for its key and as text for its kind,
each check masking the other, and its kind came from the first byte of the
text. A member is now one reading of its value, whose kind the value states.
What an allOf's branches say for the position holding them was read
again at every reader and every use, and three of those reads grew
with the square of the input:

- readVoices rendered an entry's first writer once for each later
  conjunct comparing with it, so one allOf of 20,001 branches, the
  first writing a 400 KB description, cost 559 G instructions against
  main's 10.4 G.
- annotation.Read overlaid everything read so far on each voice's
  Unmodeled, so a site paid for every voice all those before it.
- Each $ref to a composition walked the composition's folding
  branches, and 400, 800 and 1,600 uses of a decorator of as many
  branches cost 2.6, 9.1 and 35.0 G, where main is linear.

A schema's voicing, what it lends a use and its rendered text are now
read once a run through lowering.Remembered, each a function of the
source and the bindings. A first writer is rendered once, a voice adds
only the entries the site lacks (underlayUnmodeled), and a use reads
only the branches lending an entry that the schema and the branches
before them do not write (lendingBranches): every writer of an entry
agrees, so a reader taking the nearest takes the same value from any.

Storing a Use in the memo makes the binaries' reachability reach
annotation.Kind.String through Use.Site, so its apiRoots entry goes.
widenByBranch widened a position by every inline branch that speaks
for it and writes `nullable`, whatever its value, once any branch of
the allOf wrote `true`. Its premise was that a branch folding `false`
then disagrees with that `true`, but a `true` beside a $ref branch is
no fold, so nothing compares it, and the `false` branch speaks:

    {type: string, allOf: [{$ref: Str, nullable: true},
                           {nullable: false, description: d}]}

lowered to a nullable reference, where the same allOf without the
`false` branch did not.

nullableVoices now returns the branches whose `nullable: true` folds,
read once a run, since every use of a schema asks, where widensAny
walked the whole flattened allOf at each use. A read across a $ref
stops at the first such branch, as it reports nothing.
The merge of #1217 taught voicesAmong and restates to pass over a key
written as null, which reads as the keyword left out: a branch writing
`description: null` had disagreed with one writing `description: d`,
keeping d verbatim, and items restating a $ref's but for a null
keyword had kept their branch. Neither reader had a test of its own,
and TestCompile_ANullKeywordLowersAsTheKeywordLeftOut writes one branch
alone, where no disagreement can arise. These pin both, in both orders
for the first.
Brings in #1302 and #1304.

- #1302 took IRVersion 0.26.0, so this branch's bump moves to 0.27.0,
  its row keeping 0.26.0's shape.
- #1302 reads a collection bound into what a position lowered to a
  reference owns, through CarrierConstraintsOf and
  referenceConstraintsOf. A refinement lowers to a reference, so each
  takes the voices ConstraintsOf takes here, and a bound beside an
  allOf refining a $ref is typed on the alias or the carrier, which
  TestCollectionBound_BesideARefinementReachesWhatThePositionOwns pins.
  A bound on its branch still stays with the branch (#1312), so
  referenceConstraintsOf's comment and the constraints rule's status,
  which said such an allOf lowers to a Model, say that instead.
Two conjuncts writing one value bound differently disagreed, so each
branch stayed whole under Unmodeled and the position held neither:

    {allOf: [{$ref: Str}, {maxLength: 10}, {maxLength: 5, description: d}]}

kept both branches, the description with them. #1307 made a
redeclared field's bounds meet, each taking what the declarations say
together, and merge.Constraints is the meet ConstraintsOf folds voices
with, so a position's conjuncts now agree the same way: the example
holds maxLength 5 and its description, and multipleOf 4 beside 6 holds
12, in either order.

The agreement is a set's answer, read once per schema: a bound agrees
where every writer's reading of it states a value and the values meet
(merge.Meets). Two patterns have no one value, and a 3.0 exclusive
modifier with no bound beside it fills no field, so each still
disagrees with a writer spelling it otherwise. The allof-inline-residue
rule says so, and its status names the one case left, a collection
bound on a branch (#1312), in place of #10.
A refinement's inline principal that lowers as a type array dropped
what none of its variants holds, as a type array written alone did, and
the allof-refinement rule and keepsAtOutcome named that as a limit
(#852). #1217 closed it: the census asks the variants and keeps such a
keyword on the union, and a principal reaches the same census. So
`{allOf: [{type: [string, integer], minLength: 1}]}` keeps minLength on
its union, as `{type: [string, integer], minLength: 1}` does. The limit
leaves the rule's status and the comment, and
TestRefinement_ATypeArrayPrincipalKeepsWhatNoVariantHolds holds both
spellings to it.
Three statements claimed more than the code does:

- models.md said an allOf none of whose conjuncts can make an object
  is a use of one of them, and the Go and Kotlin lowering rows named
  three cases that keep such an allOf a Model. allof-refinement keeps
  composing for more: two $refs, a union on a branch, a capped or
  false branch, more conjuncts than the election compares. Each now
  says short of those cases.
- ref-use-site said a sole $ref composed with nothing only a Model
  holds lends a carrier what it lends written alone. A use reads
  through no allOf that points 2 to 4 of allof-refinement keep
  composing either (throughRef), and the sentence now says so.
- allof-refinement said an enum or a const beside an allOf wins first,
  and nothing of what that allOf keeps: it stays verbatim, folding
  nothing and lowering no $ref it writes, which its status now tracks
  as #1309.

The new type array test joins the rule's tests.
ref-use-site said "A composition of a sole $ref, a decorator or a bound
beside it, lends a carrier what the $ref would lend written alone",
which reads as three kinds of composition where it meant one, with the
decorator and the bound as examples. It also named only a conjunct
writing what only a Model holds and points 2 to 4 of allof-refinement
as what makes such a composition lend nothing, where a $dynamicRef on
a branch does too: throughRef reads past no branch refinableBranches
refuses. Compiled at the branch head,

    W1: {allOf: [{$ref: Str}, {$dynamicRef: '#meta'}]}
    W2: {allOf: [{$ref: Str}, {maxLength: 3}]}

over Str: {type: string, description: from-str, readOnly: true}, a
property naming W1 carries no description and no visibility, and one
naming W2 carries both from Str.

The two sentences now say which compositions hold Base and Mixins and
lend nothing, then that any other lends.
Brings in #1313 and #1342. Neither touches what this branch changes,
and the merge is clean. #1342 moves go.yaml.in/yaml/v3 to 3.0.6, which
decodes the null keywords this branch reads as left out.
Brings in #1229 and #1325.

- Both bump IRVersion, to 0.27.0 and 0.28.0, so this branch's bump
  moves to 0.29.0. Its row takes main's new shape and keeps this
  branch's names and meaning; the version history entry moves with it.
- #1229 gives ir.Parameter a Secret, so a parameter's redaction has a
  home and xml is the one keyword left with none. fillParamSchema keeps
  xml for each voice and the schema, and the wrapper that also kept
  format: password goes. The secret-format golden is regenerated: it
  differs from main's by this branch's cases, and from this branch's by
  the parameter's Secret.
- #1229 reads a target's mapped keys onto a carrier at the end of a
  $ref only. A refinement is a use of what it names, so
  {allOf: [{$ref: T}], description: d} read T's deprecated keyword
  but not its x-stability, where {$ref: T, description: d} read both.
  KeysLentTo now reads the keys along the chain the keywords are read
  along (Use.Referents), nearest first: the nearest link stating a
  deprecation, by a keyword or a key, decides it and lends its members,
  and any link asking for redaction asks it of the use. A position
  owning a node lends its carrier none, as it lends no keyword.
  Promoter.Referent keys what it remembers by the schema's identity, as
  an inline link has no binding position.
  TestPromotion_ARefinementLendsWhatItRefines pins it.
- TestPromotion_ADecoratorMatchesTheRefItStandsFor assumed an allOf of
  one $ref over a string is a decorator. It is a refinement here, so
  the test now holds its carriers to the $ref spelling's, and keeps the
  fold comparison over object targets, where it is still a decorator.
- #1258's example, deprecated: false on such an allOf over a string,
  now reads as beside the $ref, so its status names the object case
  that still parts, and extension-promotion's #1220 entry names what is
  left of it.
Electing a refinement's principal asks narrowsAll of every conjunct,
the $ref's among them, and for the $ref narrowsAll compared its value
set with the conjunct's, which is the same set: a walk of every member
of the target that answers nothing. A conjunct was compared with
itself too, and valuesWithin walked a larger set looking for a member
a smaller one lacks, which it must. So each position narrowing an enum
walked the whole enum, a cost of positions times members.

narrowsAll skips the $ref and the conjunct it judges, and valuesWithin
answers a set larger than the other at once, so electing a narrowing
walks the narrowing's own members. No answer changes: a sweep of
testdata, the reference corpus and the golden spec, 2,183 specs, finds
no difference in exit code, IR or diagnostics.

Instructions retired compiling 500 fields narrowing one enum, one
thread with the collector off, 3 runs alternating:

    4,000 members   3.03 G [3.02-3.07]  ->  2.58 G [2.57-2.61]
    1,000 members   1.37 G [1.37-1.37]  ->  1.25 G [1.25-1.26]

4,000 models extending a base, no value set, stay at 7.73 G.

TestElect_WalksOnlyTheSetItNarrows counts the members the election
looks up, through a function field on admission a compile leaves nil.
Brings in #1322 and #1333. Both merge clean but for the version.

#1333 took IRVersion 0.29.0, gaining Content.Docs, so this branch's
bump moves to 0.30.0. Its row takes #1333's shape and keeps this
branch's names and meaning, and its version history entry moves with
it, pinned against 0.29.0.
Deciding whether an allOf over a $ref refines it reads what the $ref
admits across its chain, up to maxAdmissionReads schemas, and each
position reads afresh. A chain of decorators, each an allOf naming the
next, so pays up to that bound at every link: 3,000 links cost 7.38 G
instructions against main's 6.14 G. A reading is not remembered,
because its answer turns on the budget left when it starts, so a
remembered one would have to carry what it spent. The limit is stated
where the bound is.
#1229, merged in fd37a88, gives ir.Parameter a Secret and holds a
parameter's format: password there rather than under openapi:format.
TestParameter_ABranchSpeaksAsItsSchemaDoes still expected the verbatim
entry for a branch writing it, so it failed on the merge. It now holds
the branch's redaction to Secret and the entry's absence, beside the
readOnly and xml the parameter still keeps verbatim at the branch.
KeysLentTo lends a carrier no key where its position lowered to a node
of its own, as the carrier reads no keyword there: a decorator over an
object holds its target's Deprecation on its node, for the fold to
read. Nothing held that, so removing the guard put the target's
promoted deprecation on every carrier of
{allOf: [{$ref: SpentObject}], description: d} and no test failed.
TestPromotion_ARefinementLendsWhatItRefines now has the row.
Brings in #1327.

- #1327 took IRVersion 0.30.0, with no change of shape, so this
  branch's bump moves to 0.31.0. Its row keeps the shape both share and
  this branch's names and meaning, and its version history entry moves
  with it, pinned against 0.30.0.
- #1327 and this branch each added a test at one place in
  preserve_internal_test.go; both are kept.
A $ref that loses the type to a value set narrowing it, or that an
enum beside its allOf passes over, still speaks on the node the
position lowers to (ref-use-site): nodeUse lists it among what that
node falls back to, so its `deprecated` reaches the node. A
declaration's promotion took no referent, so a key stating the same
deprecation did not:

    Narrowed: {allOf: [{$ref: Old}, {enum: [x]}]}

held Old's deprecation where Old wrote `deprecated: true`, and none
where it wrote `x-stability: deprecated`. The $ref is no Base of the
node, so nothing else reaches it.

The fold KeysLentTo ran for a carrier is now Use.lentKeys, and
attachDeclaredAnnotations runs it over the nodeUse its keywords come
from. A node with no lost $ref falls back to nothing, so it promotes as
before; a boolean schema's position, with no schema, writes no
`deprecated`. A sweep of testdata, the reference corpus and the golden
spec, 2,183 specs, finds no difference, as none narrows a target
deprecated by a key. TestPromotion_ALostRefLendsTheNodeItSpeaksOn pins
both spellings, and a declaration given no referent again fails it.
Brings in #1227, which resolves a $ref against the schema identifiers
each document declares and expands a $dynamicRef through the binding
table. The merge is clean, and #1227 takes no IRVersion, so this
branch's stays 0.31.0.

Every reader this branch adds reaches a target through the bindings
(the admission read, lentBy, Use.lentKeys), so each follows #1227's
resolution with the lowering: a refinement over a $ref an $id resolves
reads that target as its $ref spelling does. Swept over testdata, the
reference corpus and the golden spec, #1227 changes one spec on main
and the same one here, its own identifiers_in_another_document, with
the same IR and diagnostics on both. A $dynamicRef still ends a
refinement's read, which keeps such an allOf the composition it was.
The two Status lines tracking #1312 scoped the limit to an allOf over a
$ref, but foldEntry folds no minItems, maxItems or uniqueItems on any
inline branch. So {type: array, items: {type: string}, allOf:
[{maxItems: 2}]} keeps its branch verbatim beside a List whose
Constraints has the field, where the same bound beside the allOf, or
written flat, reaches Constraints.

State the limit for every principal in both rules, and at foldEntry,
the code that decides it.
@OmarAlJarrah
OmarAlJarrah merged commit 5457b53 into main Nov 2, 2026
4 checks passed
@OmarAlJarrah
OmarAlJarrah deleted the fix/openapi-allof-refines-a-reference branch November 2, 2026 17:04
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