Skip to content

feat(ir)!: carry both exclusive bounds; give contentSchema a home - #440

Open
fuad-daoud wants to merge 3 commits into
stack/5-promotionfrom
stack/6-constraints
Open

feat(ir)!: carry both exclusive bounds; give contentSchema a home#440
fuad-daoud wants to merge 3 commits into
stack/5-promotionfrom
stack/6-constraints

Conversation

@fuad-daoud

Copy link
Copy Markdown
Collaborator

Closes #426, #425 and #428. Breaking — one JSON type change, the only one in the stack that fails a decode rather than degrading it.

Constraints.ExclusiveMin / ExclusiveMax: bool → decimal string (#425)

In 2020-12, minimum and exclusiveMinimum are independent keywords that both apply. Constraints held one bound per side, so the two were reconciled — tighter kept, other preserved as degraded_lowering. A consumer comparing constraints across versions saw only the survivor: a change to the dropped keyword read as no change.

Both bounds are now carried per side.

Worth a close read: this also changes the 3.0 boolean dialect's semantics ({minimum: 5, exclusiveMinimum: true}ExclusiveMin "5", Min unset). Both testdata/ fixtures using exclusiveMinimum are 3.1, so no golden, no irverify, no order-invariance and no round-trip oracle reaches that arm. Unit tests do bind it — removing *incl = nil reddens two — but it is tested rather than oracled, and a reviewer flagged that as the thinnest coverage in the stack.

Encoding.Schema (#426)

contentSchema had no home at any position while contentEncoding and contentMediaType reached Encoding. ir-design.md already named it "a gap expected to close". It lowers to a TypeRef into the registry, like any other schema — not a raw blob.

$ref constraint inheritance (#428) — answered with a doc, not a fix

The issue asked why a $ref'd schema's constraints do not reach the use site while docs, deprecation and defaults do. The conclusion was that the split is deliberate, and it is now stated in the field docs so a consumer knows to read the target.

That is the answer that required no work, and it deserves a second opinion. If you would rather constraints inherit, this is the commit to send back.

BREAKING CHANGE: exclusiveMin / exclusiveMax change JSON type from boolean to a decimal string. A consumer decoding them as booleans fails rather than degrades.


Stack 6 of 8. Base stack/5-promotion — review and merge bottom-up. Every commit here passed make gate when it landed, and the full gate was re-run on the top of the stack. Run it as GOTOOLCHAIN=go1.26.3 make gate; this machine's Go 1.27 fails it for reasons unrelated to any change (#431).

🤖 Generated with Claude Code

https://claude.ai/code/session_01SYeBgDsskwnyitLgPGCPn1

fuad-daoud and others added 3 commits September 7, 2026 23:59
The 2020-12 content vocabulary was two-thirds modelled: contentEncoding
reached Encoding.Name and contentMediaType Encoding.MediaType, while
contentSchema had no field at any IR position and was always kept
verbatim under Unmodeled. A consumer saw an opaque string where the
source declared a full shape, and had to special-case one of three
keywords (GitHub #426).

ir.Encoding gains Schema *TypeRef. contentSchema's value is a schema, so
it lowers like every other sub-schema position: hoisted at its own source
pointer and referenced by ID, never carried beside the encoding as a raw
payload a consumer would have to re-parse. The pointer it hoists at is
the one the source wrote it at, which only that declaration can name, so
the minted node needs no namespace of its own.

The three keywords now share one home, so a position keeps them all or
lowers them all: contentSchema joins contentKeywords, and the schema
package decides its fate by asking the node that was built rather than
the keyword that was written. That is why annotation.noIRHomeAt goes —
whether a content keyword reached ir.Encoding is a question only the
lowering can answer, and it was answering "never" from outside.

Adding the scalar hoisters to the schema walk's recursion is what lets a
contentSchema nest; the walk's depth counter already bounds it, and
internal/archtest pins the widened cycle.

BREAKING CHANGE: contentSchema no longer appears as an
openapi:contentSchema Unmodeled entry at a position that lowers to a
Scalar; it is Encoding.Schema there. A position with no Encoding field
still keeps it verbatim, now alongside its two neighbours.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SYeBgDsskwnyitLgPGCPn1
At a $ref use site the compiler merges the referent's documentation,
deprecation and default onto the referencing Property/Parameter, and
leaves its constraints where they were declared. The split was deliberate
and tested but written down nowhere a consumer reads, so an empty
Constraints at a use site could be read as "this value is unbounded"
when it means "this position declared no bound".

The split is kept, because the two halves are not the same kind of fact.
An annotation is a single value one position may restate for another, so
use-site precedence is the only sensible rule and applying it once in the
compiler keeps every carrier alike. A bound is not: maxLength 64 on the
referent and maxLength 100 beside the $ref are both in force and the
narrower wins, so merging under use-site precedence would publish 100 as
the whole truth and lose the bound the document enforces.

What changes is that the rule is now stated where it is read: a new
ir-design §12.2, the Constraints, Property.Constraints,
Parameter.Constraints and TypeRef field docs, and the two lowering sites
that implement it. An absent Constraints at a use site means that
position declared no bound; the effective bound is its conjunction with
every node reached from its TypeRef.

param-ref-inheritance now declares a bound beside the $ref at both
carriers, so the split is witnessed rather than merely absent: the use
site's 100 lands on the carrier, the referent's 64 stays on the referent,
and the case reddens if either is copied onto the other.

Fixes #428

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SYeBgDsskwnyitLgPGCPn1
In JSON Schema 2020-12 minimum and exclusiveMinimum are independent
keywords that both apply; a schema may legally declare both, and the
same holds on the upper side. ir.Constraints held one bound plus one
exclusivity flag per side, so a co-declared pair had to be reconciled:
the tighter keyword took the slot and the other was kept verbatim under
Unmodeled as degraded_lowering.

The loser was preserved, so nothing was lost outright. But a consumer
comparing constraints across two revisions of a spec reads the fields,
not the Unmodeled map: a revision that moved only the dropped keyword
read as no change, and one that swapped which keyword was tighter read
as a change of a different kind than the one that happened (#425).

ir.Constraints now holds four bounds — Min, ExclusiveMin, Max,
ExclusiveMax, each a *BigVal, each the keyword of the same name. A
co-declared pair reaches two fields, keeps nothing beside them, and
reports nothing: there is no degradation left to announce. The
reconciliation, its exact-decimal tighter-of-two comparison, and its
diagnostics are gone; merge adopts and compares each of the four the way
it already did multipleOf.

BREAKING CHANGE: ExclusiveMin and ExclusiveMax change from bool to
*BigVal and their JSON keys gain omitempty, so `"exclusiveMin": false`
no longer appears and an exclusive bound serializes as its literal
rather than as a flag on `min`. The OpenAPI 3.0 spelling — a boolean
modifying the minimum beside it — now lowers to the bound it means:
`{minimum: 5, exclusiveMinimum: true}` becomes ExclusiveMin "5" with Min
unset, which is what the 3.1 spelling of the same restriction produces,
so a 3.0 document and its 3.1 translation no longer differ in the IR. A
3.0 modifier written with no bound to modify (invalid under draft-4, and
unchecked by the loader) is kept verbatim under Unmodeled and reported
as a warning rather than setting a flag over an absent bound.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SYeBgDsskwnyitLgPGCPn1
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.

2 participants