Skip to content

feat(ir)!: close ten IR gaps found by SpaceAPI's integration study - #434

Closed
fuad-daoud wants to merge 13 commits into
mainfrom
feat/spaceapi-ir-consumer-gaps
Closed

feat(ir)!: close ten IR gaps found by SpaceAPI's integration study#434
fuad-daoud wants to merge 13 commits into
mainfrom
feat/spaceapi-ir-consumer-gaps

Conversation

@fuad-daoud

Copy link
Copy Markdown
Collaborator

DO NOT SQUASH-MERGE THIS AS-IS, and do not merge it at all without reading the two notes at the bottom. Thirteen commits closing ten issues. It is opened for review, not because it is ready to land.

Closes #417, #420, #421, #422, #423, #424, #425, #426, #427, #428.

Why one branch

Every issue here was filed by the same study: dexpace/spaceapi is evaluating morphic IR as its input format, and is the first prospective consumer that is not an emitter. The gaps were found together, several of them interact (ErrorCase and Payload both touch lowerPayload; RemovalDate and Parameter.Provenance both touch promotion), and the IR version bump has to cover all of them at once. Splitting into ten PRs after the fact would mean ten golden regenerations of the same files.

If you would rather have them split, say so and I will split them — the commits are already one-per-issue and each passed the gate on its own.

The gate

GOTOOLCHAIN=go1.26.3 make gateexit 0 on the final tree. All ten targets, 100% coverage every package, fuzz and bench-smoke included. Each commit was also gated individually as it landed.

Note the toolchain: this machine's Go 1.27 fails the gate for reasons unrelated to any change here (golangci-lint panics building IR for the stdlib, plus one test pinning a Go 1.26 JSON escape). Both reproduce on a pristine main. Filed as #431.

BREAKING CHANGES — the union, because a squash loses the commit bodies

ir.IRVersion 0.3.0 → 0.4.0. CompatibleVersion refuses 0.3.0, so consumers recompile rather than migrate stored documents.

  1. ErrorCase loses Type; gains Name, Payload, Headers (ir.ErrorCase is second-class beside ir.Response: no Name, no Headers, no per-media-type payload #422). It is now Response's sibling and lowers through the same helpers. A 0.3.0 consumer finds no type on an error case and cannot reach its models at all. This deletes preserveErrorHeaders, fillErrorType, preserveErrorContent and the 703-occurrence "error response media type has no ErrorCase home" diagnostic.
  2. Payload gains Required (ir.Payload has no Required: body optionality survives only as an inverted Unmodeled sentinel #421), a tri-state *bool. Body optionality stops being an inverted Unmodeled["openapi:required"] sentinel read by absence — a consumer still reading that key now finds nothing and reads every body as required.
  3. Parameter gains Provenance, non-omitempty (ir.Parameter carries no Provenance, which strands x-sunset and erases parameter origin #423), and with it x-sunset promotion at the parameter position.
  4. Deprecation gains RemovalDate (Deprecation.RemovalVersion holds a date when x-sunset promotes into it #417); x-sunset promotes there rather than into RemovalVersion. A consumer reading a removal date off the version field now finds it empty. Rationale recorded in the code: a version and a date are two facts, not two spellings of one, and neither is derivable from the other without a release calendar the IR does not have.
  5. Encoding gains Schema (contentSchema has no IR field at any position #426), giving contentSchema a home at scalar positions.
  6. Constraints.ExclusiveMin/ExclusiveMax change from bool to a decimal string (A co-declared minimum + exclusiveMinimum pair loses one keyword to degraded_lowering #425) carrying the bound itself. This is the one that fails a consumer's decode rather than degrading it — the JSON type changed.

Non-breaking: #420 (detection past the sniff cap), #424 (a conflicting redeclaration's losing type is kept), #427 (x-extensible-enumEnum.Closed; the field's meaning is unchanged, only the compiler's computed value), #428 (documented as deliberate rather than changed).

Two things I want your judgement on

1. #428 was 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 and defaults do. The agent working it concluded the split is deliberate and documented it (9861f2b) rather than changing behaviour. That is a defensible reading and it is also the answer that required no work — worth a second opinion.

2. A deliberate recall loss in detection. e9ad16c scopes the key scan to the top level, which means a block YAML document quoting its top-level key ("openapi": 3.0.0 at column 0) past the 64 KiB cap is no longer detected. Admitting "openapi": at column 0 would re-open the hole for zero-indent JSON, which json.MarshalIndent(v, "", "") really produces. Against main nothing regresses — that shape was declined before too — but it narrows a capability this branch itself added.

Review found these, and they are fixed here

An adversarial pass over the whole branch (which re-ran the gate itself) caught four things the per-commit gates could not:

  • the missing version bump — nothing in CI can see it, since every test compares against the same constant
  • Format detection misses openapi past 64 KiB: Stripe's published spec is rejected #420's fix missed JSON, the motivating case: decodeFlowEntries swallowed a mid-stream error, correct for a cut prefix and wrong for a whole document. Malformed JSON with openapi past the cap still reported unrecognized-format. Fixed in e9ad16c, mutation-checked.
  • the key scan was not top-level-scoped for the quoted spelling, so a foreign document nesting an "openapi" key could be claimed as openapi/undecodable-source — this compiler claiming bytes that are not its own, which detect.go's own comments forbid. Fixed in the same commit.
  • an ir-design.md §12 sentence that read as if rule 4 had no live instances when Variant and EnumMember still are ones. Fixed in 68977f4.

It also found one defect that is not ours: a components/responses entry mounted at both a success and an error status interns its schema order-dependently, and morphic-harness reddens on it on main too. Filed separately as #433.

Verification beyond the gate

Per CLAUDE.md's "verify by executing", the agents mutation-checked their own work rather than trusting green:

  • planting a mutation that drops Name from lowerErrorCase reddened irverify's naming rule across the whole corpus
  • deleting the second media type from the extended per-status-errors fixture reddened the conformance case, proving the fixture addition is load-bearing
  • restoring the unscoped key scan reddened 7 subtests
  • the >64 KiB valid-JSON case was verified end-to-end through the real CLI, not just Detect

A differential Unmodeled-key census between a main binary and a branch binary over every testdata/** spec confirms each key delta maps to exactly one commit's intent, with no double-writes and no orphans: openapi:content 5→0, openapi:headers 1→0, openapi:required 7→0, openapi:contentSchema 2→1, openapi:exclusiveMinimum 2→0, openapi:conflicting-redeclaration 0→2. All other 60 keys unchanged.

🤖 Generated with Claude Code

https://claude.ai/code/session_01SYeBgDsskwnyitLgPGCPn1

fuad-daoud and others added 13 commits September 5, 2026 19:49
Detect capped its search for `openapi`/`swagger` at the first 64 KiB, so a
valid document that writes a large object before its version key was reported
as an unrecognized format. Stripe's published spec3.json is one: `components`
runs to megabytes and `openapi` lands at byte 2,593,401. Mapping key order
carries no meaning, so the same document with its keys the other way round
compiled fine — the format answer depended on where a writer put a key.

The 64 KiB prefix keeps its place as the fast path, and every document that
declares a key there is still answered without a full parse. When the prefix
declares neither key, a byte scan of the whole source decides whether to read
it whole: only bytes that name `openapi:` or `swagger:` as a top-level key
reach the parse, so a source of another format still gets the fast path's
silence and never a complaint from this compiler.

That scan is what Detect already used to tell its own broken source from
another format's, and it is no longer bounded to the prefix either. A document
whose prefix does not parse and whose declaration sits past the cap is now
reported as an undecodable OpenAPI source rather than declined, which is the
answer the surrounding rule always intended; detection now reads the bytes it
would have had to read to say otherwise.

Fixes #420

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SYeBgDsskwnyitLgPGCPn1
When allOf branches declare one field with incompatible types the merge
keeps the first declaration and warns, naming both pointers. The losing
declaration was then dropped: it reached the IR in no form at all, so a
consumer reading the document rather than the diagnostic stream saw no
trace of it — and a diff across two revisions in which only the losing
branch's type moved reported no change. GitHub's published spec writes
this shape 102 times.

Every other degradation in this compiler keeps what it could not model.
This one now does too: the discarded ir.TypeRef is written to the merged
property's Unmodeled under ReasonDegradedLowering, keyed by the
redeclaration's own pointer so sibling branches never overwrite one
another, and stamped with the losing declaration's provenance.

The constraint half of the same diagnostic is deliberately left alone.
It also discards the redeclaration's keyword, but the recorded direction
there is to intersect the bounds so the merged field satisfies both
branches (#10), and preserving the loser instead would settle a decision
that already has one. The code comment on keepLosingType says so.

Fixes #424

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SYeBgDsskwnyitLgPGCPn1
Request-body optionality survived only as an inverted sentinel: the OpenAPI
compiler wrote Payload.Unmodeled["openapi:required"] = false when a body was
not required and wrote nothing when it was, so recovering the fact meant
knowing an OpenAPI-specific key and reading its absence as true. A consumer
that reads typed fields alone saw every body as required — 563 times across
GitHub's and Stripe's published specs. ir/unmodeled.go grades no_ir_home as
"a gap expected to close, not a boundary", and this is that gap.

ir.Payload now carries Required *bool. The pointer is the point: a format
that expresses body optionality treats an unstated body as optional, so
folding "the format is silent" onto the same value as "the document says no"
would lose the distinction a non-OpenAPI compiler needs. Response and message
payloads leave it nil, because only a request body can be omitted.

The OpenAPI compiler always sets it, since OpenAPI's own default makes an
undeclared `required` mean false rather than unstated, and it no longer
writes the openapi:required entry or the info diagnostic that announced the
degradation — the fact is modeled now, so neither describes anything.

ir-design.md is normative on the field shapes, so §7.2's Payload and §14's
OpenAPI lowering summary are updated with it.

BREAKING CHANGE: a consumer reading Payload.Unmodeled["openapi:required"]
must read Payload.Required instead; the Unmodeled entry and its
openapi/degraded-construct info diagnostic are no longer emitted. The
per-reason reachability test moves its no_ir_home witness to a parameter's
allowEmptyValue, which still has no typed home.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SYeBgDsskwnyitLgPGCPn1
ir.Parameter was the last lowered node carrying no Provenance, and two
things followed from that.

A parameter's vendor extensions were stranded. ir-design §12 rule 4 says
a node with no provenance is not promoted into, because a promotion that
cannot be marked Inferred cannot be audited — so the parameter position
was the one ir.Deprecation carrier PromoteDeprecation was not wired at,
and a deprecated parameter's x-sunset sat unread beside an empty
Deprecation. It is wired now, and extension-promotion.yaml gains the
parameter row so the sweep fails at that carrier rather than being
covered by a neighbour.

Parameter origin was erased. mergeParameters merges a path item's
parameters into every operation on the path, and nothing afterwards
recorded that a given parameter was inherited rather than declared. The
stamp uses the pointer internal/operation already threads per parameter
for the interning fix (#36, #107): an operation's own entry points under
that operation, a $ref'd one at the component it names, and a path-item
one at the path item — one declaration named by every operation that
inherits it, which is what tells the two apart.

BREAKING CHANGE: ir.Parameter gains a Provenance field, serialized
without omitempty like every other node's. Every golden carrying a
parameter moves, and a consumer decoding the IR sees a new object on
each one.

Closes #423

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SYeBgDsskwnyitLgPGCPn1
ir.ErrorCase and ir.Response are two lowerings of one Response Object, but
only one of them could say what the source declared. Response carries a
Name whose Hint is the status spelling, Headers, and a Payload holding
every media type; ErrorCase carried none of the three — no name at all, no
headers, and one bare TypeRef where the content map belongs.

Everything that fell outside those fields went to ErrorCase.Unmodeled with
an info diagnostic, so a consumer's behaviour changed with the status
class and nothing said so:

- Retry-After and the rate-limit family live on 429 and 503, precisely the
  side with no typed home for a header.
- A 4xx declaring application/json and application/problem+json kept the
  first schema and lost the media-type key entirely; a 4xx declaring one
  media type lost the key it was written under.
- "5XX" and "default" had no faithful round-trip: StatusRange renders
  {500,599} and {0,0} with no record of how the source spelled them.

ErrorCase now has Name Naming, Headers []Property and Payload *Payload in
place of Type, each spelled as Response spells it, and the error path
lowers through the same responseName, lowerHeaders and lowerPayload the
success path uses. preserveErrorHeaders, fillErrorType, preserveErrorContent
and errorContentMessage existed only to soften this gap and are gone with
it, along with the two info diagnostics they emitted.

pass.checkEncodingKeys grows a fourth Payload carrier, reached at both
positions an ErrorCase hangs from — an operation's Errors and a service's
CommonErrors — since a check walking only the first would resolve a
service-level error's encoding keys against nothing in silence.

BREAKING CHANGE: ErrorCase.Type is removed; an error case's models are its
Payload.Contents entries' types. The JSON gains "name", "payload" and
"headers" and loses "type". ir.IRVersion is deliberately not moved here:
per ir-design.md §2.1 a line of work bumps it once, where it lands on main,
and two earlier shape changes on this branch left it alone for the same
reason.

The normative rows in docs/ir-design.md that described the old behaviour
are updated, as is the error-taxonomy example in docs/emitter-design.md.
The per-status-errors conformance fixture gains a 429 declaring two media
types and two rate-limit headers, which is what makes the new fields
witnessed rather than merely present.

Closes #422

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SYeBgDsskwnyitLgPGCPn1
x-sunset echoes RFC 8594's Sunset header, which is a date by definition,
but the default promotion mapping read it into Deprecation.RemovalVersion
— a field whose name, doc comment and sibling all say version. A consumer
deciding whether removing a deprecated operation is breaking compares a
sunset against a release date, and could not tell which spelling it had
been handed without re-parsing the string.

Take issue #417's option 1: a distinct RemovalDate beside RemovalVersion,
with x-sunset promoting to the date. A version and a date are two facts,
not two spellings of one — a document may state both ("gone in 3.0.0",
"gone on 2026-08-01"), and neither is derivable from the other without a
release calendar the IR does not have. A single field carrying which
spelling it holds (option 2) would have to drop whichever fact it read
second, so it costs losslessness to buy nothing a second field does not
already give: the field a value arrives in is what says which fact it is.

Deliberately out of scope, and stated in ir-design.md and at the reading
site: RemovalDate is the source's own text, neither parsed nor
normalized. No source format defines the field, so none defines its
format; and the key→field mapping is caller policy, so a key pointed at
the date field is the caller's statement that it holds a date. Morphic
records which fact was stated and leaves the calendar to the consumer.

BREAKING CHANGE: Deprecation gains removalDate, and x-sunset now fills it
instead of removalVersion. A consumer reading removalVersion for a sunset
reads an empty field until it moves. No default key names RemovalVersion
any more — a document stating a removal version names its own key, per
promotion rule 1 — so the corpus stops witnessing that field and it joins
unwitnessed.golden.txt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SYeBgDsskwnyitLgPGCPn1
ir.Enum has carried a Closed bool since the IR was written, and the OpenAPI
compiler set it true at both construction sites unconditionally. So the one
key the format has for saying an enum is open, x-extensible-enum, survived
only as a generic vendor_extension entry, and every consumer reading typed
fields saw a closed enum whatever the document said. Open versus closed
decides whether a generator emits a fallback member and whether a differ
calls an added value breaking, so this was a wiring gap, not a modelling one.

Add TargetEnumOpen to the promotion vocabulary, map x-extensible-enum onto it
by default, and apply it in attachDeclaredAnnotations beside the deprecation
promotion — the point at which a declaration's extensions have reached the
node's map, which is what makes "the extension survives its own promotion"
structural here as it is there. Every promotion property holds unchanged: the
entry stays put with its vendor_extension reason, the node records
extension-promotion in Provenance.Inferred, and a disabled policy writes
nothing.

The target names the fact rather than the field, which the rest of the
vocabulary does not. Openness is the only half of that bool a document ever
declares — a schema's `enum` is closed by definition — so a target named for
Closed could only ever be written false and would read as its own opposite at
every mapping naming it. For the same reason the key's presence is the
statement rather than its value: the established spelling writes the member
list as the value, and a list of members says nothing about openness the key
naming it has not already said. A boolean is the one shape that does state it
alone, so an explicit `false` is read as written rather than inverted.

Deliberately out of scope, and stated in ir-design.md and at the reading site:
a document writing x-extensible-enum *instead* of `enum`, with the members in
the extension, lowers to no ir.Enum at all and there is no node to open.
Minting one would be reading a member list out of a vendor key rather than
promoting a field; the entry survives verbatim for a consumer that wants to.

The corpus can now witness the matrix's open-enums row, so its
matrixRowsUncovered reason is deleted rather than left to go stale, and
extension-promotion.yaml gains the three enums that pin the three answers the
reading has: the convention opens one, an explicit false declines to, and an
enum naming no such key is untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SYeBgDsskwnyitLgPGCPn1
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
The whole-source read #420 added got two answers wrong, both by reusing
machinery written for a cut prefix.

The flow decoder reports "this is a flow mapping" for anything opening with
`{`, and drops the error that ended its walk. For a cut prefix that is right:
the cut always breaks the token stream, so the error describes the cut and not
the document. For a whole document it hides the document's own break. A JSON
source past the cap whose `openapi` key sits behind a syntax error came back
with a nil error, so Detect saw no failure to report and declined it as an
unrecognized format — the very answer #420 set out to replace, still standing
for every JSON source, which is the style the motivating spec is written in.
The decoder now returns the error that stopped it and treats stopping on the
mapping's own closing delimiter or on the entry cap as no error at all;
sniffPrefix drops it along with the cut that caused it, and sniffWhole keeps
it.

The key scan was widened to the whole source without being scoped to the top
level. Its block arm reads column 0 and always was top-level, but its quoted
arm matched `"openapi":` at any depth, anywhere in the buffer. Bounded to the
first 64 KiB that cost a needless parse; over a whole source it makes a claim,
and a wrong one — another format's document that nests such a key and does not
parse was reported as an undecodable OpenAPI source. Saying nothing about
bytes that are not this compiler's own is the rule detection is built on.

The quoted spelling is how flow style writes every key, so flow structure is
what scopes it: a depth-tracking scan reads the root mapping's own entries and
nothing under them, and a source that opens no mapping at all declares nothing
here. It is a lexer rather than a parser because the case it exists for is a
document broken before the key that names it, where there is no tree to ask. A
block document that quotes its top-level key is no longer seen and is declined
in silence, which is the direction to be wrong in.

A valid document past the cap that declares its version last still compiles,
which is what #420 was about.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SYeBgDsskwnyitLgPGCPn1
§12's fourth promotion rule read as if it had none left: `Parameter` was the
instance it named, and the sentence recording that `Parameter` has since gained
a `Provenance` left the rule with nothing to point at. `Variant` (§4.4) and
`EnumMember` (§4.5) each still carry a `Deprecation` with no provenance of
their own, so the rule governs them today. Name them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SYeBgDsskwnyitLgPGCPn1
Six commits on this branch change the JSON shape of a Document and none
bumped the constant, each correctly deferring per ir-design 2.1: a line of
work bumps it ONCE, where it lands on main. This is that bump.

The GoDoc on IRVersion names exactly the failure a missing bump causes -- "a
shape change that reaches main without a bump leaves a consumer pinned to the
old version accepting a document it cannot read, which is the one thing this
constant exists to prevent" -- and nothing in the gate can see it, because
TestVerify_CurrentIRVersionIsClean, TestVerify_IncompatibleIRVersionIsAViolation
and openapi_test.go all compare against the same constant and stay green
whatever it says. Found by review, not by CI.

The log paragraph records all six, each framed as what a 0.3.0 consumer gets
wrong rather than as a feature: ErrorCase loses Type and gains Name/Payload/
Headers; Payload gains Required; Parameter gains Provenance; Deprecation gains
RemovalDate and x-sunset routes there; Encoding gains Schema; and
Constraints.ExclusiveMin/Max change from bool to a decimal string, which is
the one that fails a consumer's decode rather than degrading it.

TestCompatibleVersion's neighbour rows were spelled against 0.3.0, so the
bump made "a later generation" 0.4.0 assert that the build rejects its own
documents. Re-anchored, with a comment saying they move with the constant.

79 goldens regenerated; the only key that moved is irVersion.

BREAKING CHANGE: IR documents now stamp 0.4.0 and CompatibleVersion refuses
0.3.0. Consumers must recompile rather than migrate stored documents.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SYeBgDsskwnyitLgPGCPn1
@fuad-daoud

Copy link
Copy Markdown
Collaborator Author

Superseded by a stack of eight, at your request — this one was too big to review.

PR closes breaking
1 #435 detect a version key past the sniff cap #420
2 #436 keep a conflicting redeclaration's losing type #424
3 #437 Payload.Required, Parameter.Provenance #421, #423 !
4 #438 make ErrorCase a response #422 !
5 #439 Deprecation.RemovalDate, x-extensible-enum #417, #427 !
6 #440 both exclusive bounds, contentSchema #425, #426, #428 !
7 #441 scope the key scan, keep the read's error
8 #442 bump IRVersion to 0.4.0 !

Each targets the one below it; review and merge bottom-up. The commits are unchanged and not rebased — deliberately, because every one of them passed make gate when it landed, so each stack head is already verified. Reordering them into tidier themes would have thrown that away and needed eight fresh gate runs.

The one cost of not reordering: #441 fixes two real defects in #435 (the fix missed JSON, and the key scan was not top-level-scoped) and sits at the top rather than folded into #435, because that is where the review found them. Both PRs cross-reference each other.

The branch feat/spaceapi-ir-consumer-gaps still exists and is identical to the top of the stack, if you would rather read the whole diff in one place.

@fuad-daoud fuad-daoud closed this Sep 8, 2026
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.

Deprecation.RemovalVersion holds a date when x-sunset promotes into it

2 participants