Summary
The schema lowering dispatch picks one keyword and never revisits the rest of the schema body, silently dropping legal JSON Schema conjunctions in two shapes:
oneOf + anyOf co-declared. buildUnion (and nullUnionCollapse) picks oneOf when non-empty and only falls back to anyOf when oneOf is empty. hasUnionSiblings does not treat the co-present other union keyword as a sibling, so the losing branch set gets no Extensions entry and no diagnostic.
allOf co-declared with enum/const. lower() dispatches on const, then enum, then allOf — first match wins. The narrowing idiom allOf: [$ref: Base] + enum: [...] loses the composition entirely: no Base/Mixin, no preservation, no diagnostic.
Both violate invariant 2 (lossless by default) — contrast with the oneOf/anyOf-next-to-structural-keywords case, which lowerWithUnionSiblings carefully preserves.
Reproduction
# case 1 — anyOf vanishes
S: {oneOf: [{type: string}, {type: integer}], anyOf: [{type: number}, {type: boolean}]}
# → Union with 2 variants, Extensions empty, zero diagnostics
# case 2 — allOf vanishes
Constrained: {allOf: [{$ref: '#/components/schemas/Base'}], enum: [a, b]}
# → plain Enum{a,b}; relationship to Base gone, zero diagnostics
Root cause
compilers/openapi/compose.go:178-182 (buildUnion preference), schema.go:152-169 (hasUnionSiblings blind to the other union keyword), schema.go:300-319 (const→enum→allOf precedence).
Expected
When a schema co-declares keywords the dispatch cannot jointly model, the losing keyword set must be preserved raw under Extensions with a degraded-construct diagnostic — the same treatment lowerWithUnionSiblings already gives structural siblings. Silence is never acceptable here.
Summary
The schema lowering dispatch picks one keyword and never revisits the rest of the schema body, silently dropping legal JSON Schema conjunctions in two shapes:
oneOf+anyOfco-declared.buildUnion(andnullUnionCollapse) picksoneOfwhen non-empty and only falls back toanyOfwhenoneOfis empty.hasUnionSiblingsdoes not treat the co-present other union keyword as a sibling, so the losing branch set gets no Extensions entry and no diagnostic.allOfco-declared withenum/const.lower()dispatches onconst, thenenum, thenallOf— first match wins. The narrowing idiomallOf: [$ref: Base]+enum: [...]loses the composition entirely: no Base/Mixin, no preservation, no diagnostic.Both violate invariant 2 (lossless by default) — contrast with the oneOf/anyOf-next-to-structural-keywords case, which
lowerWithUnionSiblingscarefully preserves.Reproduction
Root cause
compilers/openapi/compose.go:178-182(buildUnionpreference),schema.go:152-169(hasUnionSiblingsblind to the other union keyword),schema.go:300-319(const→enum→allOf precedence).Expected
When a schema co-declares keywords the dispatch cannot jointly model, the losing keyword set must be preserved raw under
Extensionswith a degraded-construct diagnostic — the same treatmentlowerWithUnionSiblingsalready gives structural siblings. Silence is never acceptable here.