Skip to content

openapi: a field redeclared across allOf branches does not intersect its constraints #10

Closed

Description

@OmarAlJarrah

Problem

Every allOf branch must hold, so a field two inline branches declare should carry the intersection of their constraints. A keyword only one declaration sets reaches the field where its type is a shared primitive. Two cases still leave a constraint out of the typed IR, and in both the result follows branch order.

Both declarations set a keyword. The first declaration's value is kept, even where the other is tighter, so a consumer validating against the field accepts values the spec rules out. Saved as bounds.yaml:

openapi: 3.0.3
info: {title: t, version: "1"}
paths: {}
components:
  schemas:
    LooserFirst:
      allOf:
        - {type: object, properties: {x: {type: string, maxLength: 20}}}
        - {type: object, properties: {x: {type: string, maxLength: 10}}}
    TighterFirst:
      allOf:
        - {type: object, properties: {x: {type: string, maxLength: 10}}}
        - {type: object, properties: {x: {type: string, maxLength: 20}}}
Model x's maxLength Expected
LooserFirst 20 10
TighterFirst 10 10

morphic compile --verify exits 0, each losing declaration is kept verbatim under Unmodeled, and the whole diagnostic list is:

warning openapi/conflicting-redeclaration bounds.yaml#/components/schemas/LooserFirst/allOf/1/properties/x: declarations of field "x" disagree: conflicting maxLength (20 and 10); kept what the declaration at /components/schemas/LooserFirst/allOf/0/properties/x says over the one at /components/schemas/LooserFirst/allOf/1/properties/x, which is kept verbatim under Unmodeled
warning openapi/conflicting-redeclaration bounds.yaml#/components/schemas/TighterFirst/allOf/1/properties/x: declarations of field "x" disagree: conflicting maxLength (10 and 20); kept what the declaration at /components/schemas/TighterFirst/allOf/0/properties/x says over the one at /components/schemas/TighterFirst/allOf/1/properties/x, which is kept verbatim under Unmodeled

minLength, minimum and maximum lower the same way.

The constraint sits on the type's own node. A list, and a string with an unknown format, lower to a node holding their constraints. Two such declarations are reported as an overlap and the first one's node is kept, so a bound the other adds leaves the typed IR, even where the first sets none. Saved as nodes.yaml:

openapi: 3.0.3
info: {title: t, version: "1"}
paths: {}
components:
  schemas:
    BareListFirst:
      allOf:
        - {type: object, properties: {x: {type: array, items: {type: string}}}}
        - {type: object, properties: {x: {type: array, items: {type: string}, maxItems: 5}}}
    BoundedListFirst:
      allOf:
        - {type: object, properties: {x: {type: array, items: {type: string}, maxItems: 5}}}
        - {type: object, properties: {x: {type: array, items: {type: string}}}}
    BareScalarFirst:
      allOf:
        - {type: object, properties: {x: {type: string, format: x-custom}}}
        - {type: object, properties: {x: {type: string, format: x-custom, maxLength: 5}}}
    BothBounds:
      allOf:
        - {type: object, properties: {x: {type: array, items: {type: string}, minItems: 1}}}
        - {type: object, properties: {x: {type: array, items: {type: string}, maxItems: 5}}}
Model Bounds on x's type Expected
BareListFirst none maxItems: 5
BoundedListFirst maxItems: 5 maxItems: 5
BareScalarFirst none maxLength: 5
BothBounds minItems: 1 minItems: 1, maxItems: 5

morphic compile --verify exits 0. In each model x's type is the node at allOf/0, the other declaration is kept verbatim under Unmodeled, and there is one info of this form, four in all and no others:

info openapi/degraded-construct nodes.yaml#/components/schemas/BareListFirst/allOf/1/properties/x: declarations of field "x" give types t/anon/components/schemas/BareListFirst/allOf/0/properties/x and t/anon/components/schemas/BareListFirst/allOf/1/properties/x whose values overlap but that the merge does not narrow to one; kept the type the declaration at /components/schemas/BareListFirst/allOf/0/properties/x gives over the one at /components/schemas/BareListFirst/allOf/1/properties/x, which is kept verbatim under Unmodeled

Cause

compilers/openapi/internal/merge @608a5a46.

  • (*folding).foldConstraints (fold.go) walks the declarations in order, mergeConstraints (constraints.go) keeps the first value of each keyword, and constraintsConflict reports a differing one through disagree.
  • foldConstraints reads Property.Constraints alone, so a bound on a node never reaches it. The type judgment calls the two nodes an overlap rather than one narrower, and drop sends the declaration's constraints with its type.

Direction

A proposal, to be improved when built.

Ordered keywords. foldConstraints takes the tightest value over the declarations it folds, not the first: the largest minimum, exclusiveMinimum, minLength, minItems and minProperties, and the smallest maximum, exclusiveMaximum, maxLength, maxItems and maxProperties. That value is the intersection, so it holds in every order. Whether the looser declaration keeps its warning and its Unmodeled entry is open: the tighter bound implies it, as a narrower type implies a broader one and draws no report, but allof-redeclared-property keeps a losing declaration so that a release changing it still shows in the document.

Keywords with no order. multipleOf takes the larger value where one divides the other; otherwise the intersection is their least common multiple, a value no declaration wrote. ir.Constraints.Pattern holds one string and two patterns have no single form, so pattern stays kept and reported unless the IR gains a home for a second, which is a shape change. patternMessage, precision and scale stay as they are.

Node constraints. The judgment can call a node narrower than one it equals but for bounds it adds, which settles BareListFirst and BareScalarFirst with no new node. BothBounds, where each adds what the other lacks, needs a node holding both, the step #1042 needs for its own shapes, so the two are designed together. The alternative is writing the merged bounds to Property.Constraints, as the primitive case does, which builds no node but leaves a reader of the node alone seeing fewer bounds than the field has.

Each case needs a two-order test of its own, declared looser first. The order-invariance oracle reverses mappings and leaves sequences alone, so it never swaps allOf branches and cannot see either case.

Out of scope

Related: #1042, #1043, #1269, #1288, #1289

Priority: P3, rule 3 (off the golden spec, valid-spec functionality). go run ./cmd/morphic validate "$D/openapi.json" 2>&1 | grep -c 'declarations of field' → 0, where bounds.yaml and nodes.yaml above give 2 and 4 (spec @319dc756, morphic @608a5a46).

Activity

  1. added
    priority:P2Rarer spec shapes, or an IR decision to settle before the emitter
    on Oct 19, 2026
  2. added
    priority:P3Later emitters, M4 and up, 3.2-only, streaming, webhooks, rare constructs
    type:bugDefect in shipped behavior
    and removed
    type:featureNew capability or enhancement
    priority:P2Rarer spec shapes, or an IR decision to settle before the emitter
    on Nov 1, 2026
  3. changed the title [-]openapi: intersect constraints and defaults when reconciling allOf branches[/-] [+]openapi: a field redeclared across allOf branches does not intersect its constraints[/+] on Nov 1, 2026
  4. OmarAlJarrah commented on Nov 1, 2026

    @OmarAlJarrah
    MemberAuthor

    Rewrote this against morphic @608a5a46, since the fold has moved and part of the original request has landed.

    Landed. For a field typed by a shared primitive, a keyword, default, description or example that only a later declaration writes reaches the field in either order, and a declaration that disagrees is kept verbatim under Unmodeled with a diagnostic. mergeProperty and compilers/openapi/schema.go are gone; the fold is compilers/openapi/internal/merge.

    Still open here. Two declarations setting one bound keep the first, and a bound on a type's own node never reaches the merge.

    Split out, as separate mechanisms. Judging a default against a bound on the property is #1288. Recording every contributing declaration, the second acceptance item, is #1289: Provenance holds one pointer, so it is an IR shape decision.

    Dropped. The pass/ direction: the fold runs in merge once every type has lowered, and after it a losing declaration is only a verbatim node under Unmodeled, which gives a pass no constraints to intersect. "Intersecting defaults" also goes: a default is an annotation, so two different ones have no intersection, and the fold already keeps one and reports the other.

    Labels. P2 to P3 under rule 3: the golden spec draws no redeclaration report at all, so neither open case touches it, and the probe is in the Priority line. type:feature to type:bug, since the body now states a defect, matching #1042, #1083, #1180 and #1194.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    priority:P3Later emitters, M4 and up, 3.2-only, streaming, webhooks, rare constructstype:bugDefect in shipped behavior

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions