Skip to content

feat(ir)!: scope servers to operations by ID, home wire facts - #1144

Merged
OmarAlJarrah merged 13 commits into
mainfrom
feat/ir-servers-and-wire-facts
Oct 30, 2026
Merged

OmarAlJarrah merged 13 commits into
mainfrom
feat/ir-servers-and-wire-facts

Conversation

@OmarAlJarrah

@OmarAlJarrah OmarAlJarrah commented Oct 27, 2026 •

Copy link
Copy Markdown
Member

Summary

An OpenAPI operation can write four things the IR held only as no_ir_home Unmodeled entries, so an emitter had no typed field to read for them:

Construct Was kept as Now lowers to
Path-item and operation servers openapi:servers, openapi:operationServers on each operation Document.Servers entries named by the new Operation.Servers
Header style, explode openapi:style, openapi:explode on the header's Property Header.Style, Header.Explode
Parameter allowEmptyValue openapi:allowEmptyValue on the Parameter HTTPParamBinding.AllowEmptyValue
Encoding allowReserved openapi:encoding/<part>/allowReserved on the Content PartEncoding.AllowReserved

The issue's fifth row, an error response's media types, needed no change: ErrorCase.Payload already carries one Content per media type.

Servers are entities now

Servers were the one named entity referenced by list index, and that model could not hold an operation's own servers. An index stops naming its server once a pass removes or reorders an entry. And "an empty Service.Servers means every server" would have sent a service to servers only one operation uses. So:

  • ir.ServerID (kind prefix server) is derived from the pointer of the Server Object that declares the server, in the declaring document's namespace. Server gains ID and Provenance.
  • Document.Servers is a registry, map[ServerID]Server, like Types, Channels, Messages and Auth. It holds the servers the source declares, whichever object declares them. Two OpenAPI lists stay out of it, as their objects' rules already have it: a list a nearer layer of a path item $ref overrides (reported as a collision), and one inside a component path item no reference reaches (kept whole with the component).
  • Service.Servers and Channel.Servers are []ServerID, most preferred first. Operation.Servers is new: the servers the operation is sent to in place of its service's. Empty on an operation means its service's apply. Empty on a service or channel means no declared server. A format that reads a channel naming none as served by every server, as AsyncAPI does, lists them all.
  • OpenAPI's / for a document whose servers is absent or empty is minted as server/default and records no pointer. This settles ir: Server has no provenance, and the default server the parser injects is lowered as if declared #672's second half: the injected default is no longer indistinguishable from a declared server.

In the OpenAPI compiler, a new internal/servers package owns the lowering, and both walks reach it, as they do internal/auth. The service walk names the document's servers. Each operation names its own, else its path item's, else none. A path item's list is read once per mount, whatever number of operations that mounts, and each operation holds its own copy of the IDs. A path item mounted from two paths declares its servers once, and both mounts name the same IDs. A path item's list is registered even where every operation overrides it, so the override loses no declaration, and so is the list of a path item that mounts no operation. The warning such an item draws now names only what landed: its servers registered, what else it writes kept beside it, or both. Server variables now keep the order the document writes them in; the verbatim copy had sorted them by key. A key a path item's or an operation's Server Object does not define is now kept on the server and reported as an openapi/unknown-object-key warning, as the document's own servers' are; it used to ride silently inside the verbatim list.

The operation walk's per-service accumulators (the type registry, the operationId claims and now the server registry) travel as one walk value rather than as separate parameters.

Serialization facts

  • HTTPParamBinding.AllowEmptyValue sits beside AllowReserved. A parameter's binding already held every other serialization fact, so its absence read as an oversight. OpenAPI calls the field NOT RECOMMENDED, but a document that writes it still states it, Swagger 2.0 writes it too, and a request validator or mock server needs it. OpenAPI says it SHALL be ignored under a style whose empty value its style table leaves n/a (spaceDelimited, pipeDelimited, deepObject). That combination now draws an openapi/inapplicable-location-keyword warning from the validation table, as an allowReserved with no effect does. The field is a plain bool beside AllowReserved, since a declared false means what an absent one does.
  • Header is a new type, and Response, ErrorCase and PartEncoding hold Headers []Header. It embeds Property and adds Style and Explode, which hold what a header declares, nil or empty when it declares nothing, so a declared explode: false is distinguishable from none. It is a type rather than two fields on Property because those fields would mean nothing on a model's or a resource's property, and a check over a hand-named list of positions would have to keep them off; here that state cannot be written, so no check is needed. Property.Args takes the check's route because a field argument can sit at any depth of a model, while a header is a list of its own. A Header encodes flat, so a headers entry is a property's object with two more optional members. Style is a field rather than a constant because Swagger 2.0's response-header collectionFormat writes space- and pipe-delimited lists.
  • PartEncoding.AllowReserved closes the same gap for an encoding. partEncodingEmpty, which leaves an empty part encoding out of Content.Encoding, lists fields by hand. It now reads the new field too, so a part declaring only allowReserved keeps it, and a new test fails if a later PartEncoding field is left out of that list. As on a parameter, a declared false lowers as unset.

The decisions and their reasoning are written where the next reader reaches them: ir-design §10 (servers), §7.2 (Header) and §8.1 (allowEmptyValue). emitter-design §8.10 says how a client uses an operation's own servers, and §3.4's ParamBind carries a binding's escaping, empty-value and media-type facts beside its style. ir-spec-matrix gains a per-op-servers row for the capability, witnessed by four OpenAPI conformance specs.

Left out on purpose

Closes #285
Closes #672

Breaking

The IR shape changes, so IRVersion moves to 0.22.0, recorded in ir-design §2.1 and the generations table.

  • Document.Servers is map[ServerID]Server, not []Server. Service.Servers and Channel.Servers are []ServerID, not []int, and an empty list now names no server rather than every server.
  • Server gains ID and Provenance, and Operation gains Servers. HTTPParamBinding gains AllowEmptyValue and PartEncoding gains AllowReserved.
  • Response.Headers, ErrorCase.Headers and PartEncoding.Headers are []Header, not []Property. Field reads go through the embedded Property unchanged; a function taking a []Property of headers takes a []Header.
  • Undeclared keys in a path item's or an operation's servers now draw openapi/unknown-object-key warnings.
  • The OpenAPI compiler no longer writes the openapi:servers, openapi:operationServers, openapi:allowEmptyValue, openapi:style, openapi:explode, openapi:encoding/<part>/allowReserved or openapi:itemEncoding/allowReserved entries. It drops the info openapi/degraded-construct findings that announced them, and the per-item openapi:pathItem/<item pointer>/servers entry for a path item that mounts no operation. A declared false for allowEmptyValue or an encoding's allowReserved, which the verbatim copy kept, is no longer recorded, since it means what an absent one does.
  • The diagnostic code ir/server-index-out-of-range, which pass/validate and irverify both reported, is gone. A server reference that names nothing is ir/dangling-server-ref, as every other class of ID is.

Test plan

  • make gate passes on the head, the exactly-100% statement check included.
  • Goldens, ir/testdata/shape.golden.txt and testdata/unwitnessed.golden.txt are regenerated. Every golden line the change moves is accounted for: each removed Unmodeled entry pairs with a removed degraded-construct info and a new field. The census loses Service.servers and gains nothing: every new member is witnessed by a committed spec.
  • Planted defects each turn a test red: path-item servers overwriting an operation's own; two operations sharing one list; an overridden list registered; a path item's servers left unregistered, or their findings dropped; an operation's own servers left unnamed, or their findings dropped; a declaration re-lowered per mount; a server minted in the source's namespace though another document declares it; the namespaces omitting another document; no default server, one recording a pointer, or one minted in the declared space; Root reading through the getter, or a context with no document; Servers handing out the registry itself; variables sorted; a header's Style or Explode left unread; a Header encoded nested rather than flat; AllowEmptyValue or a part's AllowReserved left unread; partEncodingEmpty ignoring AllowReserved; the n/a-style rule removed; irverify skipping server registry keys or ID agreement, not taking ServerID for an ID, or spelling a path per server in either registry check; the warning for a path item that mounts no operation naming servers no layer of it landed, or that another layer landed; the order oracle skipping the server registry, or comparing variables by position.
  • Probe: a spec writing all four constructs compiles with no degraded-construct findings, --verify clean. The operation with its own servers names them; its sibling names the path item's. A path item $ref'd from another file registers its servers once in that file's namespace, provenance naming that source.
  • Golden spec (cloudflare/api-schemas openapi.json at ef1d30e): main and this branch both exit 1 on the same 9 errors. This branch's diagnostics are main's less 11 infos (9 path-item servers, 2 allowEmptyValue), and nothing is added. The 9 operations that had only an openapi:servers entry now name their servers, and the service names the document's one server. Commands: morphic compile openapi.json -o out.json, then jq '[.. | objects | select(has("bindings") and has("servers"))] | length' out.json → 9. Five alternating runs of each build show no measurable cost: main's median is 108.8B instructions retired (range 103.8B to 109.8B), this branch's 105.9B (104.3B to 108.2B), and peak memory is level.
  • Over testdata/ and a local corpus of real-world specs, 2,165 documents against main: no exit code changes. For all 410 operations main kept servers for, the URLs this branch's Operation.Servers names equal the list main kept, in order, the operation's own where it wrote one. For all 2,072 documents with IR, Service.Servers names what main held in Document.Servers, in order.
  • morphic-harness gives the same verdict per spec on main and this branch over the same two trees, 2,162 specs, with its order oracle now comparing the server registry. A separate two-order diff of each operation's and service's servers over the 2,057 documents compiling in both orders, 129 of them with an operation's servers, finds none different.
  • The unknown-keys census covers a path item's and an operation's server, and the conformance check refuses the new matrix row when its witness entry is removed.
  • A defect planted on a Header and on a model's property draws the same irverify and pass/validate codes, so embedding narrowed no check; the one difference is Args, which validate has only ever read on a model. A compiled document with an unknown or repeated member in a header is refused at decode. Server count does not grow the allocations of Verify or Validate: 812 and 693 at 500 servers, 853 and 716 at 2,000.

An OpenAPI operation could write four things the IR held only as
no_ir_home Unmodeled entries: a path item's and an operation's own
servers, a header's style and explode, a parameter's allowEmptyValue
and an encoding's allowReserved. Each now has a typed home.

Servers become named entities. ir.ServerID is derived from the pointer
of the Server Object that declares the server, and Server gains ID and
Provenance. Document.Servers is a registry keyed by ServerID holding
every server the source declares, wherever it declares it.
Service.Servers and Channel.Servers name servers by ID instead of
indexing a list, and Operation.Servers is new: the servers an operation
is sent to in place of its service's. An empty service or channel list
now names no server rather than every server, since the registry also
holds servers only one operation uses.

The OpenAPI compiler lowers servers through a new servers package that
both walks reach. The service names the document's servers. An
operation names its own, else its path item's, else none, and a path
item two paths mount declares its servers once. A document whose
servers field is absent or empty is served at the "/" OpenAPI supplies,
minted as server/default with no pointer, so it is no longer
indistinguishable from a declared server.

HTTPParamBinding gains AllowEmptyValue, PartEncoding gains
AllowReserved, and Property gains Style and Explode, which only a
header carries; pass/validate reports either on any other property
(ir/header-serialization-outside-header). An allowEmptyValue written
under a style that cannot write an empty value is now reported as
having no effect.

BREAKING CHANGE: the IR shape changes, so IRVersion is bumped.
Document.Servers is a map, Service.Servers and Channel.Servers are
[]ServerID with new empty semantics, and the openapi:servers,
openapi:operationServers, openapi:allowEmptyValue, openapi:style,
openapi:explode and openapi:<scope>/allowReserved entries are no longer
written. The diagnostic code ir/server-index-out-of-range, which
pass/validate and irverify both reported, gives way to the general
ir/dangling-server-ref.
Main took IRVersion 0.20.0 for the flattened-properties change, so this
branch's shape change moves to 0.21.0: the constant, its generations
row and its ir-design history entry. The goldens are regenerated over
the merged code.
The split-spec golden gains a path item and an operation in paths.yaml
that write servers. Each is registered in paths.yaml's namespace at the
pointer it is declared at there, and the operation names its own, which
the test asserts beside the golden so a server minted in the source's
namespace fails it rather than only moving a golden line.
Main took IRVersion 0.21.0 for the typed inferred-marker list, so this
branch's shape change moves to 0.22.0. The operation walk's signatures
keep both changes: the walk accumulator this branch threads, and the
ir.Inferred marker main typed. The test of the finding an operation's
own servers drew stays deleted, as this branch removes that finding.
A path item's and an operation's Server Objects now lower as the
document's do, so a key the Server Object does not define is kept on
the server and reported as an unknown-object-key warning, where before
it rode silently inside a verbatim copy of the list. The census fixture
gains one such key at each level and the census test a row for each, so
the rule that every object is judged covers them.
Document.Servers was described as holding every server the source
declares. Two declared lists are not lowered: one a nearer layer of a
path item reference overrides, and one inside a component path item no
reference reaches, which is kept whole. The IR and compiler docs now say
the registry holds the servers whichever object declares them, and the
path-item-servers rule names both exceptions.

ir-spec-matrix gains a per-op-servers row, the capability
Operation.Servers homes, witnessed by four OpenAPI conformance specs,
and irverify's comments name servers among the keyed registries.
A path item that mounts no operation drew one warning claiming both that
its servers were registered and that its extensions were kept, whichever
it wrote. The warning now names only what landed.

The server registry's Root no longer dereferences a context holding no
document, and Servers hands back a copy rather than the map the registry
keeps writing to.

The documents stop stating counts that drift: the 0.22.0 history entry
and the server reference sites no longer number what they list, and the
plan view's ParamBind is described by the facts it carries rather than as
the whole of a binding's serialization. ir-design §10 names the version
servers stopped being indexed in.
OmarAlJarrah added a commit that referenced this pull request Oct 27, 2026
An IR change meets things ir-design does not spell out: another open PR
claiming the same IRVersion, which merges silently on the constant;
classification tests that a new field, ID class or sum kind must pass,
and the one gap among them, a stale entry that passes; guarantees a map
held that a list does not; and predicates that name a struct's fields
one by one.

morphic-changing-the-ir loads for work under ir/ or pass/, the IR and
emitter design documents, and IRVersion. It links ir-design §2.1 and
architecture §2.5 for the bump and landing rules rather than restating
them, and names the bump no test sees: a change of meaning with no
change of shape. The four checker rules about the IR's own verifier and
codecs move here from verifying-changes.

irversion_peers.py lists the open PRs that claim an IRVersion past
main's and reports a collision or a claim that trails main; with the
working tree set to 0.22.0 it reports #1144's claim of the same.
@OmarAlJarrah

Copy link
Copy Markdown
Member Author

This PR and #1147 meet in three places, and none of them shows as a merge conflict: git merge of this head (6e53019) onto #1147's (c04bbb0) succeeds, and the merged tree does not build. Whichever of the two lands second needs all three.

  1. A dead import. pass/validate_indices.go keeps "maps", which neither change uses once both are in: this PR stops sorting channels through maps.Keys, and perf!: allocate nothing per sound value in irverify and pass #1147 stops sorting SuccessStatus keys through it.

    pass/validate_indices.go:5:2: "maps" imported and not used
    
  2. The new cost test's servers. perf!: allocate nothing per sound value in irverify and pass #1147's TestValidate_ASoundValueCostsNothing builds a document whose service and channels name a server, in today's shape. Against this PR's registry it no longer compiles (cannot use []ir.Server{…} as map[ir.ServerID]ir.Server, and []int{…} as []ir.ServerID). In pass/validate_cost_test.go the fixture becomes Servers: map[ir.ServerID]ir.Server{"server/x/api": {ID: "server/x/api", …}} on the document and Servers: []ir.ServerID{"server/x/api"} on the service and each channel.

  3. checkHeaderSerialization pays per value. That test holds every rule family to costing less than one allocation more per sound entity as the document doubles. This PR's rule builds string(id)+"/properties" for every model, and where+"/identifiers" and where+"/properties" for every resource group, before it knows of a violation, so on the merged tree it fails:

    family 6: 1317 allocations for 666
    

    Passing the owner's ID and the field down to appendHeaderSerializationDiags and spelling the location in the branch that reports clears it: fmt.Sprintf("%s/%s/%d", owner, field, i) there, called with (string(id), "properties"), (string(g.ID), "resource/identifiers") and (string(g.ID), "resource/properties"). That keeps every location as it is spelled now.

With those three on the merged tree, go build ./... succeeds and go test ./pass passes.

main's allocation test holds every validate family to costing nothing per
sound value, so the header rule spells a location only for a diagnostic, and
its fixture names servers in the registry's shape.
Property.Style and Property.Explode meant something at three of the six
positions holding a Property and nothing at the others, so a validate rule
kept them off a model's and a resource's properties, over a hand-named list
of carriers and through ir.ForEachGroup, a traversal nothing else called.

A headers list is a list of its own, so the type can hold the rule.
Response, ErrorCase and PartEncoding now hold []Header, a Property with
Style and Explode beside it. It encodes flat, so a headers entry keeps the
members it had. The rule, its diagnostic code and ForEachGroup go, and a
model's properties no longer pay for two fields they cannot use.

Breaking: Response.Headers, ErrorCase.Headers and PartEncoding.Headers are
[]Header, not []Property, and Property has no Style or Explode.
A path item's list was read once per operation it mounts, through a registry
and a list threaded on the carrier that holds what a path item keeps. Servers
are not kept, they are named, and a fact belongs to the level that owns it.

mountPathItem now reads the list once, through itemServers, and an operation
naming none of its own takes a copy of the IDs, so the override sits in one
place and carrier is back to what it holds. Root drops a check for a server
it could only have registered itself: it runs once a compile.

Tests pin what the mount now owns: that two operations do not share a list,
that each layer of an unmounted item says what it landed, and that a finding
on an operation's own server reaches the caller, which nothing had held.
The order oracle compared the type registry alone, on the reasoning that an
ID-keyed registry is unchanged by a permutation unless a lowering collided.
Document.Servers is such a registry now, and a server is lowered at whichever
mount reaches it first, so it answers to the same question.

A server's variables follow the mapping that declares them, so they are
compared as a set, as a model's properties are.
TestRegistryChecks_ACleanEntryCostsNothing holds every check to what its
entries cost, and its fixture held every entry a check ranges over but the
servers checkIDs and checkRegistryKeys now range over, so spelling a path per
server drew nothing.
Each limit the change leaves open is named where a reader of that code or
section reaches it: an empty servers list at Registry.Declared (#1081), the
unmounted warning's site at preserveUnmountedPathItem (#1164), and a success
response's headers in emitter-design (#1163).

applyPathItemDocs cited #285 for a path item's summary and description, which
that issue never covered and this change closes; #383 is where they are open.
ir-design said a binding carries every serialization fact beside a list that
left out Prefix and PathPattern, so it no longer claims to list them.
@OmarAlJarrah
OmarAlJarrah merged commit 8a239d8 into main Oct 30, 2026
2 checks passed
@OmarAlJarrah
OmarAlJarrah deleted the feat/ir-servers-and-wire-facts branch October 30, 2026 15:27
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