Skip to content

fix(compilers/openapi): intersect a redeclared field's bounds - #1307

Merged
OmarAlJarrah merged 13 commits into
mainfrom
fix/openapi-allof-intersect-bounds
Nov 2, 2026
Merged

OmarAlJarrah merged 13 commits into
mainfrom
fix/openapi-allof-intersect-bounds

Conversation

@OmarAlJarrah

@OmarAlJarrah OmarAlJarrah commented Nov 1, 2026 •

Copy link
Copy Markdown
Member

Summary

Every allOf branch must hold, so a field two branches declare should carry the intersection of what they say. The fold kept the first declaration's bound and reported the other, so the field was looser than the spec, and which value it held followed branch order.

Declarations of x Before After
maxLength: 20, maxLength: 10 20, with a warning 10, in both orders
multipleOf: 4, multipleOf: 6 4, with a warning 12, in both orders
a list, a list with maxItems: 5 the first list, maxItems under Unmodeled the second list
a list with minItems: 1, one with maxItems: 5 the first list, maxItems under Unmodeled a list holding both
default: hello, maxLength: 3 a default the field refuses the default kept verbatim, with an info

Three things change, all in compilers/openapi/internal/merge, with the rule in docs/compilers/openapi.md#allof-redeclared-property.

Bounds on a property take their intersection. Each ordered keyword takes its tightest value, by magnitude, so no order decides it. multipleOf takes whichever step is a multiple of the other, and otherwise their least common multiple, a number no declaration wrote, computed exactly and without building a wide power of ten. pattern, patternMessage, precision and scale have no order and no one value saying two of them, so they stay kept and reported. A bound the field implies draws no report and no Unmodeled entry.

Bounds on a node reach the field. Two Scalars, lists, maps or tuples alike in all but their bounds are now judged by those bounds. The one saying all the other does is narrower and wins in either order. Where neither does, a new verdict says the two meet, and the field's type is a node minted in the composed namespace, holding the winner's detail and every bound. Alike means in every fact a node holds but where it was declared, deprecation and XML hints included, so a node kept or minted for two loses nothing either holds; two containers differing in such a fact stay an overlap, reported and kept.

A value is held to the bounds others brought. A default or example is judged against each bound the field keeps that another declaration contributed, on the property or on a node. A bound the declaration states itself at the value the field keeps stays its own to answer for, so {maxLength: 3, default: hello} written once lowers as it did.

Decisions a reviewer should weigh

  • A looser declaration is neither reported nor kept (openapi: a field redeclared across allOf branches does not intersect its constraints #10 left this open). allof-redeclared-property kept a losing declaration so that a release changing it still shows in the document. A looser bound loses nothing: a release changing it changes no value the field admits, which is exactly why a broader type already draws no report. Keeping it would list every narrowing as a loss.
  • multipleOf computes a value no declaration wrote, rather than keeping one and reporting the other. Keeping one leaves a validator accepting values the spec refuses, with branch order choosing which. The multiple's value set is exact. A pair past 1,024 digits, or a multiple past what a BigVal holds, is kept and reported as a keyword with no order is.
  • A node is minted for BothBounds, rather than writing the difference to Property.Constraints. Bounds conjoin across a property and its node (ir.Constraints), so that would be sound, but no property holds an item bound today: a list's and a tuple's bounds stand on their node alone, and a $ref beside minItems keeps it verbatim for having no home. It would also leave the field's type following branch order. A minted node gives one home, in every order, and is the step openapi: an allOf narrowing between two enums or two lists is resolved by branch order #1042 needs.
  • A field keeps one provenance pointer, closing openapi: decide whether a merged field records every declaration that contributed to it #1289 as its option 2. A list of declarations on Property would say which contributed and not which wrote each keyword, and no consumer reads either. Adding it later is an ordinary versioned change. The sentence "every branch stays reconstructible from the composition" was false for a folded declaration and now says what holds, and the decision is in the rule.
  • No IRVersion bump. No key changes shape or meaning, and every member filled here is already filled for other inputs. A Property naming a node in composed is new for a non-model, so the sentences about that namespace were read and ids.go's were corrected; docs/ir and docs/emitters state nothing that assumes a composed node is a model.

Limits, each stated in the rule

Other open PRs

Not stacked, and main is merged in at 172a9f03, which holds #1217, #1219 and #1226. #1226 adds an ID namespace every golden lists, so the merge regenerated allof-redeclared-bounds.golden.json by that one entry. #1269 conflicts in constraints.go, fold.go, conflict_internal_test.go and matrix.md, and needs more than a textual merge: it lowers an allOf at one position and keeps two differing bounds, citing #10, so whichever lands second gives that position the same intersection, through mergeConstraints. #1302, #1278, #1229 and #1227 conflict with main itself, and with this branch in no file beyond those. #1199, #1233, #1235, #1277, #1301, #1304, #1311 and #1313 merge cleanly.

Three commit subjects pass the cap with their number, the third at 76 characters, the seventh at 74 and the tenth at 75 (merges aside). They are pushed, so they stay, and the title is what lands.

Golden spec

cloudflare/api-schemas @319dc756, main @172a9f03 against the head after merging it: IR and diagnostics byte-identical on both builds, each exiting 1 under --verify, and grep -c 'declarations of field' is 0 on each. Neither issue touches it, as their priorities say.

Test plan

  • The specs in openapi: a field redeclared across allOf branches does not intersect its constraints #10, openapi: a merged field keeps a default that a bound on the property refuses #1288 and openapi: decide whether a merged field records every declaration that contributed to it #1289 compiled literally, each in both orders: every row of their expected tables holds, and --verify exits 0.
  • An ordered-pair and three-declaration grid of 9,301 models over strings, numbers, lists, maps, tuples and both scalars of an unknown format, with --verify. A validator written from JSON Schema's definitions judged an instance pool against the source declarations and against what a reader conjoins from the property and its node chain. No instance the source admits is rejected. None it refuses is admitted unless reported and kept, and all 368 such reports are two patterns, against 11,402 diagnostics on main. Permutations of a set agree on what they admit unless a tie is reported. A planted defect reading a meet as a narrowing drew silent loosening and 38 order-dependent sets, so the oracle can fail. cmd/morphic-harness passes over the same spec.
  • The same oracle over three more grids: a 3.0 document of 5,726 models with nullable and boolean exclusiveMinimum/exclusiveMaximum; 3,095 models where every other declaration is a $ref to a component; and 2,996 adding objects with property bounds and lists of formatted items. None rejects a value the source admits, none loosens a bound without a report, and the field admits null exactly where every declaration does.
  • A keyword-presence differential: 1,560 folds of lists, maps, tuples and scalars, in narrowings, looser bounds and meets, each compiled with and without one of 40 keywords on one declaration, on both builds. Every keyword left a trace on the branch but the five in the limit above, and no exit code changed. It missed that a container was compared on only part of what its node holds: with deprecated or xml on the declaration that did not win, a field kept or minted the other node and dropped it silently, the next item but one.
  • Declarations in another document, through a $ref into it: a field there mints its node in that document's composed namespace. This found that the check letting a declaration answer for its own value ran without knowing where each declaration was written, so a default on a list in another document was refused while the same declaration in the root document was adopted. Fixed in the seventh commit, with TestFold_AValueOnAListInAnotherDocumentIsJudgedAsItsOwn.
  • A list, map or tuple differing from another in its bounds and in a deprecated or xml it writes, in both orders: until the last commit the field took the narrower node, losing the broader one's fact in both orders, or a node minted from the first, losing it in one, and neither drew a diagnostic. Containers are now compared as Scalars are, on everything but where each was declared, so the pair is an overlap, reported and kept in either order, as on main. TestAllOf_ANodeHoldingMoreThanBoundsIsKeptInEitherOrder reddens on each container row with the earlier comparison; its scalar row is the control. The fix changes no exit code, IR or diagnostic over the 2,177 specs of testdata and the reference corpus, under --verify.
  • Two-order tests declaring the looser bound first, in TestAllOf_ABoundTakesItsTightestValueInEitherOrder, TestAllOf_ANodesBoundsReachTheFieldInEitherOrder and the allof-redeclared-bounds case, since the order oracle never swaps a sequence. The merge's own tests fold three declarations in every order.
  • sweep.py against main over testdata and the reference corpus, with --verify, 2,172 specs: exit codes identical (1,651 zero, 521 one), and 2 differ. One is the new case. The other is ogen's allOf.yml, whose objectsWithConflictingArrayProperty is the minItems beside maxItems shape: it kept maxItems: 5 only under Unmodeled, and now holds both on one list.
  • Shapes the grid does not reach, compiled with --verify and through the harness: a distributed union, whose variants mint one node; a wrapper with a $ref into its branch; each nullability; two components; a discriminator's property; hoisted elements; nested bounds.
  • 100 planted defects at each new condition, call and keyword, each read against the test naming it: 100 killed. Seven survived at first and drew new tests, among them a dropped declaration's bound reaching a minted node. Three of those first read as kills, being test timeouts on a cold build cache, and were survivors once rerun. An eighth showed a guard was equivalent, so the guard is gone. After merging main, 20 more planted afresh at the bounds, step, judgment and value checks: 20 killed.
  • Cost. Tuples are the one new branching recursion: before their positions were limited to a field's own depth, a 14 KB document of nested tuples, each position a $ref to its sibling, ran past 40 seconds where main takes 0.03, and it now takes 0.06. TestTypeJudge_ATupleOfTuplesCostsItsWidthNotItsWidthToTheDepth counts the reads. Bounds cost a constant per pair: with every field declared 64 times, the most a fold judges, 200 fields take 2.1 s of CPU on main and 7.7 s here, and 400 take 4.2 s and 14.9 s, so it stays linear in the document. Golden draws no redeclaration report, and takes 14.5 s of CPU on each build.
  • go test ./... and go vet ./... at 0f8c5414, golangci-lint v2.14.0 at 0 issues, and 100% of statements on merge, schema, ids and openapitest. At the head, after merging main at 172a9f03: go build ./..., go vet ./... and go test ./..., and 100% of statements on merge, schema, ids and openapitest. The full gate is CI's.
  • go test ./cmd/morphic-census: no fact gained or lost a witness.

Closes #10
Closes #1288
Closes #1289

Every allOf branch must hold, so a field two branches declare carries what
both say. The fold kept the first declaration's bound and reported the other,
so the field was looser than the spec and branch order chose its value. A bound
on a type's own node never reached the fold, and a default was never held to a
bound another declaration wrote on the property.

Each ordered keyword now takes its tightest value, and multipleOf the step
whose multiples are those both admit. A keyword with no order, pattern among
them, stays kept and reported. Two nodes alike but for their bounds are judged
by those bounds: the one saying all the other does is narrower, and where
neither does the two meet, and the field takes a node minted in the composed
namespace holding both. A bound the field implies is no loss, so it draws no
report and no Unmodeled entry.

A value is held to each bound another declaration brought. A bound a declaration
states itself at the value the field keeps stays its own to answer for.

A field keeps one provenance pointer: a list of declarations would not say which
wrote each keyword, and no consumer reads one. The rule's claim that every branch
stays reconstructible was false for a folded declaration and now says what holds.

Closes #10
Closes #1288
Closes #1289
Two planted defects survived. Minting a node beside a composition passed because the test's stub lets a second mint overwrite the first, where Register refuses it, so the test now counts mints. Reading any related pair as alike passed because no case had a declaration narrower by its element write a value, so one now does.
A planted defect reading every declaration into a minted node survived, and it would put a dropped declaration's bound on the field, so a case now has one drop beside two that meet. No row had a narrower list holding detail against a broader one, so two sides of holdsDetail went unread on any verdict.

The check that any declaration meets the winner is gone. With none, the only bounds read are the winner's own, which leaves the winner's node, so it skipped work on a rare path and said nothing.
A tuple has a member per position where every other node has one, and a $ref to a sibling position adds width at no cost in document size, so comparing nested tuples cost width^depth: a 14 KB document ran past 40 seconds where main takes 0.03. A tuple another type holds is no longer compared, as it was not before tuples were judged, which leaves the work linear in a tuple's width. A read-counting test holds that.

One keyword a position writes on its node and its property was reported twice where the two folds kept it from different declarations, since the check compared whole messages. It now compares what disagrees.
Extract the two types takes compares, name the bounds detailRelation holds aside, and give a test closure its own subtest's t rather than the parent's.
Say plainly when a folded declaration is reported, and rewrap what the rule's edits left past the document's width.
A value's declaration answers for it where its type is the field's but for bounds, but that check ran on a judge holding no provenance, so two lists in another document read as declared apart and a default on one was refused. The judge now holds where each declaration was written.

The multiple of two steps now goes through NewBigVal, the constructor every other bound takes, which also refuses one past the range. The rule and the judgment's comment said a pair gets one verdict as a field and as members, which tuples and meets make false, and each now says so. The rule names the tests this work added since, and the test of flipped holds the new verdict.
Two lists, maps or tuples were compared by their bounds, encoding,
documentation, examples and Unmodeled, and by nothing else a node holds.
Folding containers by their bounds turned that gap into silent loss: a
list writing minItems beside one writing maxItems and deprecated: true
minted a node from whichever was declared first, so the deprecation
survived in one branch order only, and a narrower list kept alone
dropped a broader one's XML hints. Neither drew a diagnostic, where both
were reported and kept while differing bounds made an overlap.

A container is now compared on its whole TypeCommon but where it was
declared, as sameNode already compares a Scalar, so a node kept for two
loses nothing either holds. Containers that differ in such a fact are an
overlap: the second declaration is reported and kept verbatim, in either
order.
The fold gives the field the type that holds the most declarations'
types, and a meet counts as holding. The rule still described the count
as the same or narrower, and widestCone's comment still said a type
holding every other is the intersection, though two types that meet
hold each other and neither is: the node minted from the winner is.

Declaring a field as an array of numbers with minItems, one with
maxItems and two arrays of integers shows the difference: the field is
the minted array of numbers holding both bounds, and the integer
declarations are reported, where the old sentence would pick the
integers.
The unresolved auth namespace main added lists in every golden's ID
spaces, so allof-redeclared-bounds.golden.json gains it here.
@OmarAlJarrah
OmarAlJarrah merged commit 7cf3f95 into main Nov 2, 2026
2 checks passed
@OmarAlJarrah
OmarAlJarrah deleted the fix/openapi-allof-intersect-bounds branch November 2, 2026 01:19
OmarAlJarrah added a commit that referenced this pull request Nov 2, 2026
Brings in #1226 and #1307.

- #1226 took IRVersion 0.25.0, so this branch's bump moves to 0.26.0,
  its row keeping 0.25.0's shape. Its new unresolved auth namespace
  reaches allof-refinement.golden.json's idSpaces, and lowering.Ctx
  lost WithAuth, so TestRemembered_IsReadOnceAcrossCopies derives its
  copy with InScope.
- #1307 made the constraint merge a meet: each ordered keyword takes
  the tighter value. Its body is kept under the exported name this
  branch gives it, merge.Constraints, and the tests named after the old
  name follow it. Voices that write one bound alike still meet to that
  bound; bounds that differ still keep their branches, which the next
  commit changes.
OmarAlJarrah added a commit that referenced this pull request Nov 2, 2026
#1307 made the allOf fold intersect a redeclared field's bounds, the
collection bounds among them. A bound this change puts on a carrier
therefore folds the same way: two declarations writing minItems 1 and 2
give 2 in either order, with nothing kept and nothing reported, where the
fold had kept the first and reported the other. The test pinning the
fold follows.
OmarAlJarrah added a commit that referenced this pull request Nov 2, 2026
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.
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