Skip to content

feat(ir)!: give Payload a Required field and Parameter a Provenance - #437

Open
fuad-daoud wants to merge 2 commits into
stack/2-redeclarationfrom
stack/3-ir-fields
Open

feat(ir)!: give Payload a Required field and Parameter a Provenance#437
fuad-daoud wants to merge 2 commits into
stack/2-redeclarationfrom
stack/3-ir-fields

Conversation

@fuad-daoud

Copy link
Copy Markdown
Collaborator

Closes #421 and #423. Breaking — two IR field additions.

Payload.Required *bool (#421)

requestBody.required survived only as an inverted sentinel: Unmodeled["openapi:required"] = false written when the body is not required, nothing written when it is. A consumer had to know an OpenAPI-specific key and read its absence as true.

ir/unmodeled.go classifies no_ir_home as "a gap expected to close, not a boundary" — this is that gap. Tri-state on purpose: absent stays distinguishable from false, for a format that does not express body optionality. 563 occurrences across GitHub and Stripe.

Downstream this is not merely a missing feature: dexpace/spaceapi renders the literal heading Request body (required) off this boolean, so on any IR not produced by the OpenAPI compiler it would print a confidently wrong statement.

Parameter.Provenance (#423)

ir.Parameter was the only lowered node without one, and params.go carried a comment saying so. Two consequences: x-sunset promotion was not wired at the parameter position, and after mergeParameters merges path-item parameters into every operation, nothing recorded that a parameter was inherited rather than declared.

An inherited parameter now carries the path item's own declaration pointer, not the operation's.

BREAKING CHANGE: Payload gains required; Parameter gains a non-omitempty provenance. A 0.3.0 consumer reading openapi:required now finds nothing and reads every body as required.


Stack 3 of 8. Base stack/2-redeclaration — 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 2 commits September 9, 2026 22:31
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

@Wahbeh-Mohammad Wahbeh-Mohammad left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review of the branch as it will merge (95c2735). Three majors, three minors, one nit. Inline comments carry the ones anchored to changed lines; the two below sit on files this PR did not touch.

IRVersion stays 0.3.0 while the JSON shape changes (major)

ir/document.go:22 is untouched, yet this PR adds a non-omitempty provenance to every Parameter, adds required to Payload, and removes the openapi:required Unmodeled entry. The constant's own doc comment calls a shape change reaching main without a bump "the one thing this constant exists to prevent". The bump exists only on stack/8-irversion, and the stack squash-merges bottom-up, so main carries the new shape under the old version for the whole interval between this PR and stack/8.

Suggested fix, either one: land the stack as a single merge and say so in the PR body, or add a paragraph to the IRVersion doc comment now stating that the 0.4.0 shape changes are in flight on stack/* and naming the PR that carries the bump, so a reader of main in between is not left to infer it.

schema.Preserve is now exported for nobody (minor)

This PR deleted the only caller outside package schema (the openapi:required write at the old content.go:743). At the head, every remaining caller is in-package: compose.go:209, schema.go:465, schema.go:497, and schema_internal_test.go:191. The sibling Preserve* helpers still have operation callers, so this one is the odd one out rather than part of a uniform surface.

Suggested fix: rename to preserve and update the four in-package sites. schema_internal_test.go is already an internal test, so no export_test.go is needed.

Not verified here

No Go toolchain on the reviewing machine, so nothing was executed. Two things need GOTOOLCHAIN=go1.26.3 make gate to settle: the hard-coded count of 3 in TestDiag_SharedDeclarationReportsEachDefectOnce after the fixture swap, and the 100% coverage gate on the new lines.

Comment thread ir/operation.go
// several operations — a path-item parameter in OpenAPI, merged into every
// operation on the path — points at its own single declaration rather than
// at the operation it was merged into, so a consumer can tell an inherited
// parameter from one the operation declares.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Major. This sentence does not hold for $ref'd parameters, which is the dominant shape in the GitHub and Stripe specs #423 cites.

mergeParameters computes the mount-site pointer per entry, but lowerParameters (params.go:34) then calls resolve.ObjectAt, which returns the resolved target pointer for a $ref, and lowerParameter stamps Provenance from that. sp.pointer is used nowhere after resolution. So {$ref: '#/components/parameters/X'} on an operation and the same $ref on the path item both land on /components/parameters/X. The corpus already contains both halves: component-reuse.golden.json has a path-item $ref (sort) at /components/parameters/Sort, and the new params_test.go:372 asserts an operation-level $ref (page) at /components/parameters/Page. Only inline entries keep the distinction.

The consequence is worse than "incomplete": the only discriminator a consumer has, "is the pointer under the operation's own pointer?", misclassifies every operation-level $ref as inherited while working on inline ones, so it looks correct until it isn't. Same applies to webhook and callback operations, since all three mergeParameters call sites share this path.

Suggested fix (the small one): narrow this comment, the matching sentence in docs/ir-design.md (§7.2 Parameter block), and the test comment at params_test.go:359 to what the field delivers: "points at its declaration site; for a referenced entry that is the component, and the mount site is not recorded". Then state in the PR body that inherited-vs-declared is answerable only for inline entries. Recording the mount site as well (a second pointer or an Unmodeled key) would make the claim true, but that is a bigger change and can be its own issue.

Comment thread ir/operation.go
type Payload struct {
// Contents holds one entry per media type / message schema — all kept.
Contents []Content `json:"contents,omitempty"`
// Required states whether the message may be omitted: true = the body must

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor. The lead clause is inverted relative to the field name and the mapping that follows it: "states whether the message may be omitted: true = the body must be sent". docs/ir-design.md (the Payload sketch in §7.2) has it the right way round, so the two normative texts disagree on the first sentence.

Suggested wording:

// Required states whether the message must be sent: true = the body is
// mandatory, false = it may be omitted. nil = ...

Comment thread ir/operation.go
// that does, an unstated body is optional, and collapsing that onto nil
// would make "the format is silent" indistinguishable from "the document
// says no". A response or message payload leaves it nil: only a request
// body can be omitted.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor. This rule is documented and not enforced. Nothing in pass/validate.go, ir/irverify, or internal/harness reads Payload.Required, so a document carrying Required on a Response.Payload or Message.Payload passes every oracle. That is exactly the failure #421 was filed about: an emitter rendering "Request body (required)" off the boolean would print it on a response.

checkEncodingKeys in pass/validate.go already walks the three Payload carriers (Operation.Request, Response.Payload, Message.Payload) and that enumeration is drift-guarded by TestEncodingCarriers_NameEveryPayloadFieldInTheIR, so the predicate has a home and needs no new list.

Suggested fix: add checkPayloadRequiredOnlyOnRequest beside it, emitting pass/payload-required-outside-request at severity error, with a hand-built ir.Document as the negative fixture since no compiler produces the shape. Fine as a follow-up if you would rather keep this PR to the two fields, but then say so in the doc comment.


return map[string]promotionCarrier{
"operation": {op.Deprecation, op.Provenance, op.Unmodeled},
"parameter": {param.Deprecation, param.Provenance, param.Unmodeled},

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Major. This row closes one instance of the #423 defect by adding to a hand-written list that nothing holds to the IR. The IR declares eight Deprecation *Deprecation carriers (grep -rn 'Deprecation \*Deprecation' ir/); this map names six. Variant (ir/types.go:258) and EnumMember (ir/types.go:305) are the two missing, and both still lack a Provenance, so they remain un-promotable-into today.

Nothing is broken at this head: the compiler's seven ir.Deprecation{} construction sites all have a matching PromoteDeprecation. But the mutation "add a seventh carrier to the compiler with no PromoteDeprecation call" reddens nothing, which is the exact shape of defect this PR fixes. CLAUDE.md's rule for hand-written kind lists applies: TestTypeDef_HandWrittenKindListsAreComplete exists so that adding a kind without updating a list fails there instead of silently narrowing coverage, and pass/validate_carriers_test.go:52 already does the same reflectively for Payload carriers.

Suggested fix: a reflective test that walks the IR for struct types carrying both a Deprecation and a Provenance field and requires each to appear in this map, with Variant and EnumMember in an explicit exempt list annotated "until they gain a Provenance".

Related: the rewritten promotion rule 4 in docs/ir-design.md (around line 1810) now reads as a historical note that names no live instance, while Variant and EnumMember are live instances. Either name them or drop the instance list and keep only the rule.

@@ -1243,7 +1243,7 @@ components:
required: false
content:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit. This required: false no longer contributes to anything the test observes. TestDiag_SharedDeclarationReportsEachDefectOnce counts diagnostics only, and after this PR there is no assertion that the shared component's optionality reaches Payload.Required on both postA and postB. TestContent_FullPipeline does cover explicit required: false on a single use site, but no fixture under testdata/ declares it, so every golden "required": false comes from an omitted keyword.

Cheap way to earn the line: assert *op.Request.Required == false on both operations here. That also pins that the shared component body is lowered to two distinct *bools rather than one aliased pointer, which is true today (lowerRequestBody takes a fresh local per call) but unpinned.

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.

3 participants