Skip to content

feat(ir)!: give parameters, groups and enum members stable IDs - #807

Closed
fuad-daoud wants to merge 6 commits into
mainfrom
feat/ir-stable-ids
Closed

fuad-daoud wants to merge 6 commits into
mainfrom
feat/ir-stable-ids

Conversation

@fuad-daoud

Copy link
Copy Markdown
Collaborator

Summary

Closes #596. Closes #673. Part of #255 (item 1, enum-member identity; items 2–4 have no code yet, and their decisions are recorded on #255).

Three named things in the IR had no stable identity, so anything that needed to point at one could only use its name. Names are presentation (invariant 3), and here they also collide. This gives each of the three a synthetic ID and moves every reference onto it. The three changes ship together because each changes the IR's shape, and together they make one version bump: 0.6.0 → 0.7.0.

Parameters: ParamID (#596)

OpenAPI identifies a parameter by (name, in), so id in the query and id in the path are both legal on one operation. The IR referenced parameters by name, so their two bindings could not say which one they bound.

  • Parameter.ID is param/<space>/<operation mount pointer>/parameters/<name>/<in>.
    • It is scoped per operation. A path-item parameter is copied into every operation on the item, and a $ref'd path item can be mounted at several paths, so the mount pointer is what keeps each copy distinct.
    • It uses no list index, so reordering parameters leaves every ID unchanged.
  • References. HTTPParamBinding.Param, Pagination.InputCursor/InputLimit (ParamPath.Param) and Idempotency.TokenParam now hold a ParamID.
  • Validation. pass/validate rejects any of them naming a parameter its own operation does not declare. An ID nobody declares stays with the existing dangling-reference check, so it is reported once.

Operation groups: GroupID (#673)

emitter-design §3.4 derived GroupKey from a group identity that did not exist. A declared tag default and the fallback group for untagged operations both came out as default.

  • Declared tags get g/openapi/tags/<escaped tag name>, flat, with no parent chain.
  • Synthesized groups (the fallback, webhooks, path-prefix) get IDs under g/synth/openapi/…, so they can never equal a declared group's ID.
  • Synthesized names step aside when a declared tag already uses the same canonical words. So the fallback becomes default_2 beside a tag default, and the same rule applies to the webhook group.
  • OperationGroup also gains Provenance.
  • GroupKey is defined in emitter-design §3.4 as carrying the group's ID.
  • 3.2 tag nesting is openapi: OpenAPI 3.2 tag parent and kind are dropped #613 and out of scope. The flat ID is chosen so nesting can land later without renaming any group.

Enum members: EnumMemberID (#255, item 1)

emitter-design §4.12 resolves naming collisions in a table keyed by IR ID, and the commonest collision, enum members canonicalizing to the same words, had no ID to key it.

  • Scope and source. EnumMember.ID is scoped to its enum and derived from the member's wire value, never its position, so reordering or inserting members changes no existing ID.
  • Encoding. The value encoding is kind-tagged, so 1 and "1" differ.
  • Repeated values. Both members are kept. The second takes a #2 suffix, and the compiler warns openapi/duplicate-enum-value.
  • Docs. §4.12 names EnumMember.ID as the collision-resolver key.

Checks

  • pass/validate reports duplicate group IDs and duplicate member IDs within one enum.
  • irverify reaches all three classes. Empty and duplicate IDs are found automatically through its reflection-derived declarations. The grammar and scope checks for groups and members are hand-written.

Breaking

  • IRVersion is 0.7.0. CompatibleVersion accepts only an exact match, so a stored 0.6.0 document is refused.
  • HTTPParamBinding.Param, ParamPath.Param and Idempotency.TokenParam change from a parameter name to a ParamID.
  • Parameter, OperationGroup and EnumMember gain a required id. OperationGroup also gains provenance.
  • Goldens are regenerated: 89 files under testdata/.

Test plan

  • make gate passes on the branch tip after merging main (build: move to Go 1.27.2, golangci-lint v2.14.0, openapi v1.25.5 #804: Go 1.27.2, golangci-lint v2.14.0), including 100% statement coverage, lint, fuzz and bench smoke. The statement count fell from 10000 to 9352 with the merge. build: move to Go 1.27.2, golangci-lint v2.14.0, openapi v1.25.5 #804 changes no Go code; Go 1.27.2 counts fewer statements per coverage block, with the same block set (checked on compilers/openapi/internal/schema: 1005 blocks under both, 1535 → 1453 statements). Run with GOFLAGS=-p=4, because two wall-clock-budget tests (TestDetectCycles_MergeChainPastBoundStaysFastAndWarns, TestResolverOracle_RefusalMatchesResolverBehavior) overrun under the full parallel race run on a loaded laptop. They do the same on main; see test: replace the merge-walk timing ratio with a deterministic bound #746.

  • Every new test was checked by planting the defect it guards against and watching it go red, then reverting. For example:

    • an index-derived ID (params, groups, members);
    • in dropped from the param ID;
    • a fixed default hint;
    • an unsuffixed repeated enum value;
    • each new validate and irverify check removed.
  • Two-order tests: parameter declaration order, tag and path order, and enum member order each leave every ID unchanged.

  • Goldens are not a no-op regeneration. Deleting a parameter, a tag, or an enum member from a source spec reddens the suite without -update.

  • Probes with morphic compile/validate:

    All of them validate with exit 0.

🤖 Generated with Claude Code

https://claude.ai/code/session_016MQFAkYBVaqgSxGwe9ZJSn

fuad-daoud and others added 6 commits October 9, 2026 17:28
A Parameter was identified by its display name, so an operation with id in
the query and id in the path could not say which one a binding, a
pagination cursor or an idempotency token meant.

Parameter gains a required ID of the new ParamID class, minted as
param/<space>/<operation path>/parameters/<name>/<in>. The operation scopes
it because a path-item parameter is copied into every operation on the
item; no list index is used, so reordering leaves IDs unchanged.
HTTPParamBinding.Param, ParamPath.Param and Idempotency.TokenParam now hold
a ParamID, and pass/validate joins bindings to parameters by it, rejecting
an ID the binding's own operation does not declare.

Breaking: IRVersion 0.6.0 -> 0.7.0; the three references changed from a
name string to an ID, and goldens are regenerated.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
An OperationGroup had no identity beyond its display name, so a declared
tag called "default" and the group the compiler synthesizes for untagged
operations were indistinguishable, and nothing could reference a group.

OperationGroup gains an ID of the new GroupID class (kind prefix g) and a
Provenance. A tag-declared group is g/openapi/tags/<escaped name>, the
name as one injectively escaped segment with no parent chain, so
reordering tags cannot rebind an ID to another tag. A group the compiler
synthesizes (untagged fallback, webhooks, path-prefix) lives in
g/synth/openapi/<rule>[/<key>], a space no declared ID can reach. The
hint of a synthesized group yields to any declared or used spelling.

pass.Validate reports a repeated group ID as pass/duplicate-group-id and
irverify holds each ID to its space's grammar. GroupKey in the emitter
design carries the ID.

Breaking: documents gain id and provenance on every group; the 0.7.0
history entry records it, goldens are regenerated.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
An EnumMember was told apart only by its position, so reordering or
inserting members changed what every later one meant to a consumer.

EnumMember gains a required ID of the new EnumMemberID class, minted as
e/<space>/<enum path>/<key>. The key derives from the member's value
(kind-tagged, injective, canonical BigVal decimal for numbers), never
from its index or canonical name. A repeated value is kept; later
occurrences take a #n suffix and the OpenAPI compiler warns once per
repeat (openapi/duplicate-enum-value). pass.Validate rejects a repeated
ID within one enum, and irverify holds each ID to its enum's scope.

Breaking: extends the 0.7.0 entry (no further bump); EnumMember gains a
required id key and goldens are regenerated.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Pagination.InputCursor, Pagination.InputLimit and Idempotency.TokenParam
were only checked document-wide, so they could name another operation's
parameter and validate clean. They are now held to the operation's own
Params, with pass/param-binding-mismatch, as HTTP bindings are. An ID no
operation declares stays with the dangling-reference walk.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Picks up #804 (Go 1.27.2, golangci-lint v2.14.0, openapi v1.25.5).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016MQFAkYBVaqgSxGwe9ZJSn
@fuad-daoud fuad-daoud self-assigned this Oct 9, 2026
@fuad-daoud

Copy link
Copy Markdown
Collaborator Author

Closing in favour of a replacement built on current main. #801 landed operation-group identity (closing #673) and declared ID namespaces (Document.IDSpaces) while this was in review, so this branch now conflicts in 105 files and carries a second, incompatible group-ID scheme.

The replacement keeps what #801 does not cover:

Both new ID kinds will be registered in #801's namespace declaration and held to its verifier rules. The IR version moves to 0.8.0, since 0.7.0 is already on main. The fallback-group name collision from #673 goes in a separate small PR.

@fuad-daoud

Copy link
Copy Markdown
Collaborator Author

Replaced by #811.

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