Repository navigation
fix(compilers/openapi): keep format: password at every position - #592
fuad-daoud wants to merge 7 commits into
Conversation
#579) formatTable mapped string/password to ir.PrimString, the same kind `type: string` alone selects, so scalarTypeID returned the shared primitive and nothing anywhere recorded the format. The one position that worked was an inline property, where FillPropertyDetail read the literal; a $ref, array items, additionalProperties, parameters and content schemas all lost it silently. A position whose format is password now hoists a Scalar of its own carrying Sensitive and Encoding.Name "password", exactly as byte and an unknown format do, and FillPropertyDetail reads the referent beside the use site so a $ref to a redaction schema is secret like an inline one. No new diagnostic: the fact reaches a field everywhere now, and the parameter-level carrier stays with #578. - delete the "string/password" row and add redactionFormat + hoistRedactionScalar beside the byte hoister - TestFormatTable_EveryRowDistinguishesTheBareType pins the invariant the row broke: a pairing may only map to a kind its bare type does not - new conformance case secret-format.yaml asserts every position plus the guard that a plain {type: string} keeps t/prim/string and stays not sensitive - rework TestAllOf_ReconcileAccumulatesRicherDetailWhateverTheOrder onto a format-free fixture and pin the Secret OR-fold in its own case
…ader (#579) TestConformance_TableNamesEveryCorpusSpec reads the corpus directory while TestConformance's parallel subtests write goldens into it under -update. A newly added spec then reads as having no golden while its subtest is still writing it, so the first -update run over a new case fails on a race rather than a defect (reproduced 7/7 with secret-format.golden.json removed). Both tests are now sequential, which orders the writer ahead of the reader by declaration order; the writer's subtests stay parallel and each comment says which half of the ordering it owns.
8466fcf to
d447868
Compare
|
Rebased onto |
Resolve the conflicts with main, and bring the doc comments this branch adds under the 100-word cap main now enforces. The note on a format hoist dropping a redeclaration's format moves inside the function, beside the branch it explains. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GCzyGPq9DQ7RLfagZm2JB5
| func (g *Merger) recordRedeclarationConflict(dst, src *ir.Property) (dropped, lost bool) { | ||
| pointer := src.Provenance.Pointer | ||
| if dst.Type.Target != src.Type.Target { | ||
| // A format hoist alone differs here: `{type: string, format: password}` |
There was a problem hiding this comment.
Reordering allOf branches now changes the merged field, and one order silently loses detail.
Repro: allOf of {properties: {token: {type: string}}} and {properties: {token: {type: string, format: password, minLength: 8, default: x, description: d, example: e}}}.
- main, either order: one property with
default,constraints.minLength,docs.description,examplesandsecret. - this branch, plain declaration first: type is
t/prim/string,secret: true, and the default, constraints, description and example are gone from the property; the whole password declaration is parked underopenapi:conflicting-redeclaration<ptr>. - this branch, password declaration first: type is the hoisted
t/anon/.../allOf/0/properties/tokenand the default and constraints are kept. - no diagnostic in either order.
mergeVisibility documents that allOf gives branch order no meaning, and the order-invariance oracle does not reorder allOf branches, so the corpus sweep cannot see this. The comment here ("the format drops") understates it: it is every shape detail of the redeclaration that drops, and in the password-first order it is the plain declaration that is dropped instead.
Suggest treating a format-only hoist difference as a compatible narrowing (fold the details, pick the richer type independent of order) rather than leaving it to #446, or at minimum reporting it.
There was a problem hiding this comment.
Three corrections to this comment.
- The PR body already lists this under accepted behaviour changes, and I should have acknowledged that. The trade-off is a regression only for
password, whichmainfolds because it resolves to the shared primitive. - The asymmetry is not new for formats in general. An unknown format already behaves this way on
main:hex-tokenbeside a plainstringgivest/prim/stringwith no default or bounds when the plain branch is first, keeps both when the format branch is first, and reports nothing in either order. That is now openapi: an allOf redeclaration of a property with an unknown format is resolved by branch order #782.byteis not part of it; it reports an incompatible-types warning. - The in-code pointer to typesConflict reports a format-narrowed primitive as conflicting with its bare primitive #446 does not cover this. typesConflict reports a format-narrowed primitive as conflicting with its bare primitive #446 and openapi: an allOf that narrows a property's type is reported as a conflict and resolved by branch order #598 are about
typesConflictreporting a conflict between primitive kinds that can both hold. HeretypesConflictresolves the Scalar tostring, reports nothing, and the shape fold still discards one side. Please point this comment at openapi: an allOf redeclaration of a property with an unknown format is resolved by branch order #782 instead.
I did not find this shape in a sweep of 157 real-world specs that use format: password, so the cost is the order dependence and the lost typed fields, not something it hits in practice.
There was a problem hiding this comment.
Repointed to #782 in 291a221. The comment now says the later declaration is dropped whole, its shape details with it, and that which declaration that is depends on branch order. I left the fold itself to #782: it is the same mechanism that hex-token already hits on main, and fixing it only for password would leave the two disagreeing.
| // any other declaration-scoped, use-binding fact (annotation.EffectiveVisibility, | ||
| // fillPropertyDefault): a $ref to a redaction schema states the redaction at | ||
| // the use, and the reference node's own format is empty. | ||
| if ref.GetFormat() == redactionFormat || |
There was a problem hiding this comment.
Property.Secret is still false for two common spellings of a reference to a redaction schema, even though the Sensitive node sits right behind them.
Probed with Pw: {type: string, format: password}:
{oneOf: [{$ref: Pw}, {type: "null"}]}and theanyOfform: type isPw(nullable),secret: false.{allOf: [{$ref: Pw}], description: ...}(the usual way to annotate a$ref): anonymous Model withBase: Pw,sensitive: false,secret: false.{$ref: Pw}:secret: true.- An alias component
Chain: {$ref: Pw}issensitive: falseas well, so a parameter or array item typedChainhas no redaction signal on its own type node.
The ir-design row says Property.Secret is set beside the node at a property or a header, so an emitter that builds a redacting String() from Property.Secret would emit these fields in clear.
There was a problem hiding this comment.
Scoping this: nothing is lost. In each of these spellings the property's type still reaches the Sensitive node (the nullable ref targets Pw, the allOf form has Pw as its base), so a consumer that checks both homes sees the redaction. The gap only bites a consumer that reads Property.Secret alone.
I did not find it in a sweep of 157 real-world specs that use format: password: all 259 properties whose type reaches a sensitive node also have secret: true. I would treat this as a consistency fix rather than a data-loss bug.
There was a problem hiding this comment.
Fixed in 291a221. MarkSecretUses now runs once lowering is done and sets Secret on every property and header whose type reaches a Sensitive node: directly, through the nullable target, or along a Base chain. Probed on the branch: nullable oneOf, nullable anyOf, allOf: [{$ref: Pw}] with a description, an alias component, and a nullable response header are all secret: true now. The fixture covers each of them, plus an alias declared after its first use. Removing the MarkSecretUses call reddens the conformance case.
| // fillPropertyDefault): a $ref to a redaction schema states the redaction at | ||
| // the use, and the reference node's own format is empty. | ||
| if ref.GetFormat() == redactionFormat || | ||
| (tgt != nil && tgt.GetFormat() == redactionFormat) { |
There was a problem hiding this comment.
Secret is derived here by re-reading the schema literal (the position's own format plus one referent hop), while Sensitive is derived from the lowered node. The two "homes a consumer reads as the same statement" diverge as soon as a wrapper (nullable union, allOf, alias component) sits between the use and the Scalar; see the other comment on this line.
Deriving Secret from the resolved TypeRef (follow the nullable-ref target and the Base chain, OR in the node's Sensitive) would keep a single derivation and close those gaps without enumerating spellings in FillPropertyDetail.
There was a problem hiding this comment.
One more reason to derive Secret from the resolved type. The doc on redactionFormat says the constant is read by both the lowering arm and the per-use carrier, "so the two cannot drift". They can: the two share the predicate but not the schema they apply it to (the position's own format plus one $ref hop, versus the lowered node), which is what the cases in the comment above show. Deriving Secret from the node, or dropping that claim from the comment, would fix it.
There was a problem hiding this comment.
Done in 291a221, with one constraint: the derivation can't happen inside FillPropertyDetail, because a $ref names a component's ID before that component is lowered, so at fill time there is often no node to read. It runs as a walk after lowering, following the nullable target and the Base chain. The fill-time read of the declaration's own format stays, because the allOf fold ORs it across redeclarations, and in the drop case of #782 it is the only record of the losing branch's redaction. The redactionFormat comment now names both writers and no longer claims they cannot drift.
| // must still surface every optional detail, so branch order never loses | ||
| // information (the reverse of the forkee documented-first shape). | ||
| // information (the reverse of the forkee documented-first shape). The richer | ||
| // branch writes no format on purpose: a format that hoists a node of its own |
There was a problem hiding this comment.
The richer branch used to write format: password; it is removed here and the comment says it "writes no format on purpose". That input is the one that regressed (see the comment on merge.go): a redeclaration whose format hoists a node no longer folds its default, constraints, description and examples, and this test can no longer see it. TestAllOf_RedeclarationOrsSecret only asserts Secret.
Please keep a format-bearing richer branch here, or add a case asserting that default and constraints survive when the redeclaration hoists a node, so the behavior is pinned rather than exempted.
There was a problem hiding this comment.
| // Keyed on the format rather than on the string type, so `format: password` | ||
| // on a non-string type and on a union variant states the same redaction: | ||
| // both are lossless today only by accident. | ||
| if format == redactionFormat { |
There was a problem hiding this comment.
Hoisting a Scalar at a CarriedRef position moves the schema's description, deprecated, xml and examples off Property and onto an anonymous node.
Probe: a model property {type: string, format: password, description: "the pwd", deprecated: true, example: hunter2, xml: {name: foo}}.
- main:
Property.Docs.description,Deprecation,XMLandExamplesare all set. - this branch: all four are empty or absent on the property and present only on
t/anon/.../properties/pw.
It matches the byte and unknown-format controls, but password is by far the most common hoisting format, and an emitter that documents or deprecates a field from Property.Docs/Property.Deprecation loses both for every password field. If the node-per-position carrier stays, FillPropertyDetail could keep these on the property the way it keeps constraints ("Constraints stay unconditional" above).
There was a problem hiding this comment.
Retracting the suggested fix here. The diagnosis was right; the remedy is not.
An inline enum, object or unknown-format property already behaves this way on main: description and xml sit on the node the position owns, and Property.Docs is empty. That is the single-reader design from #114 and #116 (attachDeclaredAnnotations runs above the dispatch so no lowering destination can forget them), and the PR body lists the move under accepted behaviour changes. Keeping a second copy on the property in FillPropertyDetail would add back the second reader those issues removed, and would make password the odd one out.
I also checked that nothing is lost. With description, title, example, deprecated, xml, x-*, externalDocs, default and bounds written on a password schema at each of 58 schema positions, every one of them is still in the IR, only relocated. Whether an emitter should read Property.Docs or fall back to its type's docs is an IR-level question (#465 covers the neighbouring override rule), not something to settle in this PR.
There was a problem hiding this comment.
Agreed with the retraction: the annotations stay on the node the position owns, as for every other hoisting position. No change here.
| | Format | Lowering highlights | | ||
| |---|---| | ||
| | **OpenAPI 3.x** | components/schemas → registry (IDs from pointers); inline schemas hoisted with hints; `allOf` → Base/Mixins per §4.3; `oneOf`/`anyOf` → Union (Exclusive bit), null-variant → Nullable ref, a null-only branch set (every branch a bare `type: null`) → the same nullable `any` a bare `{type: null}` schema at that position lowers to, with the oneOf/anyOf itself kept verbatim Unmodeled (`degraded_lowering`) since no node built for it carries a branch set, co-declared with structural keywords → the composition distributed across the variants per §4.3, or — for the five shapes that cannot be distributed — structural body + verbatim union per §4.8 (branches that declare no shape at all are `validation_only` per §4.7); the same co-declared `oneOf`/`anyOf` beside a `$ref` at the same level, rather than a structural body, has nothing at that position to distribute the union across either, so it lowers to the alias over the `$ref` target with the union kept verbatim beside it (`degraded_lowering`), through the same keeper as the structural-body case; an `allOf` beside a `$ref` at the same level joins the alias's unhomed-keyword census the same way (`degraded_lowering`), since a `$ref` site elects no composition family for it to lose or win; competing keywords at one position — `const`/`enum`/`allOf`, elected in that order; `oneOf` beside `anyOf`, where oneOf wins; and a parameter's or header's `schema` beside its `content`, where content wins, since a media-type entry names both a schema and the media type serializing it and the IR models both — lower as the elected keyword with every passed-over one verbatim Unmodeled (`degraded_lowering`) at its own pointer per §4.8, and a `{X, null}` oneOf beside an anyOf stays a Union rather than collapsing to a nullable ref; `discriminator` → Discriminator (3.2 `defaultMapping` → Discriminator.Default), and a subtype the mapping names under several keys — an alias tag — takes the smallest in byte order as its DiscriminatorValue, since the field holds one and a mapping's key order is not a property of the document, with every key kept in the base's Mapping and the election reported (`degraded-construct`, info); `nullable`/type-arrays → Nullable; readOnly/writeOnly → Visibility and schema-level `default` → the referencing Property/Parameter's Default: both bind a *use* of the type rather than the type, so a declaration-site one is pushed down to referencing properties with use-site precedence and the declaration keeps its own copy verbatim (`no_ir_home` Unmodeled + diagnostic) — which is what a component nothing references would otherwise lose silently; `additionalProperties: false` → Additional=closed, `unevaluatedProperties: false` → closed_after_composition, `minProperties`/`maxProperties` → Model.Constraints (the property set's cardinality, as against Additional's openness); parameters → Params + HTTPBinding locations w/ style/explode, `allowEmptyValue` → Parameter.Unmodeled (`no_ir_home`: HTTPParamBinding holds its neighbours but not this one), a header parameter named Accept/Content-Type/Authorization lowered as declared + `reserved-header-name` warning (OpenAPI says such a definition SHALL be ignored; dropping declared content is an emitter's call, not a compiler's), 3.2 `in: querystring` → querystring location; a declared `style` there is refused by the parser, while `explode` or `allowReserved` (which it does not check; GitHub #408) lowers as declared + `invalid-location-keyword` warning at the keyword's own pointer; requestBody/responses all content types → Payload.Contents, `requestBody.required` → Payload.Required, always set since OpenAPI's own default makes an undeclared `required` mean false rather than unstated; 3.2 `itemSchema` → Content.Item and `itemEncoding` → Content.ItemEncoding — except beside a positional `prefixEncoding`, where both go to Content.Unmodeled (`no_ir_home`) because a single every-item encoding cannot state ordinals; an Encoding Object's `allowReserved` → Content.Unmodeled (`no_ir_home`) under `openapi:encoding/<part>/allowReserved` or `openapi:itemEncoding/allowReserved`, ir.PartEncoding holding `style` and `explode` beside it but no field for this one, and read before the entry's emptiness is judged so an entry declaring nothing else is not dropped with the empty PartEncoding it lowers to; per-status responses/default → Conditions + ranges, with the responses-map key as declared and then neutralized → Response.Name.Hint and ErrorCase.Name.Hint alike (`404`, `5_xx`, `default`), which records the spelling a range cannot state though only `default` survives neutralization unchanged; two keys resolving to one range — `4XX` beside `4xx` — are both kept and reported `openapi/duplicate-status-key`, since they reach the IR with one name and one condition; an error response lowers exactly as a success one — its `headers` → ErrorCase.Headers and every media type of its `content` → ErrorCase.Payload.Contents, neither degraded and neither kept under Unmodeled, and the payload's naming hint derived from the declaration pointer on both sides so that one `components/responses` entry mounted at a success and an error status interns one type whichever side reaches it first; response/encoding header `style` and `explode` → Property.Unmodeled (`no_ir_home`, ir.Property has neither field), and a `Content-Type` entry in either headers map lowered as declared + the same `reserved-header-name` warning the parameter position gets; webhooks → HTTPBinding.IsWebhook; callbacks → Callbacks; links → Response.Unmodeled and ErrorCase.Unmodeled alike (`no_ir_home`, promotable later): the two are lowerings of one Response Object, so a construct kept on only the success one makes a declaration survive or vanish on nothing but its status code; path-item `servers`, under `paths`, `webhooks` and a callback expression alike → Operation.Unmodeled (`no_ir_home`: §10 scopes servers by index list at service and channel, and an operation has no such list yet), with an operation's own `servers` — which OpenAPI says override the path item's — kept beside them under `openapi:operationServers`, the one key here not named for the keyword it holds, since two declarations at two pointers cannot share one map key without the survivor depending on lowering order; a path item's own `summary`/`description`, at the same three mounts → Operation.Unmodeled under `openapi:pathItemSummary`/`openapi:pathItemDescription` (`no_ir_home`) rather than merged into Docs: ir.Docs holds the operation's own pair and a path item's documents the path, so merging would need a precedence rule and would attach documentation the operation's author never wrote — an inference, which §6 places in policy rather than in a lowering; every operation a path item declares — the fixed method fields, 3.2 `query`, and 3.2 `additionalOperations` keyed by method — → an Operation apiece, mounted at its own pointer, with the `additionalOperations` key used verbatim as HTTPBinding.Method since OpenAPI reads a method name case-sensitively, and a key naming no method at all lowered as declared + `invalid-method-key` warning (the binding is unusable, but dropping the entry would lose every operation it declares); securitySchemes/security → Auth OR-of-ANDs, 3.2 device flow + `oauth2MetadataUrl` → Flows/OAuth2MetadataURL; servers+variables (3.2 named) → Servers; tags (3.2 parent/kind) → groups + TagDefs; info contact/license → Document; schema `example(s)` → Examples; `xml` object (incl. 3.2 nodeType) → XMLHints at type and property level; `not`/`if-then-else`/`dependentSchemas`/`dependentRequired`/`contains`/`propertyNames`/`unevaluated*` → verbatim Unmodeled per §4.7; `contentEncoding`/`contentMediaType`/`contentSchema` → `Encoding` on the scalar the position lowers to — the last as `Encoding.Schema`, a TypeRef to the decoded shape hoisted at its own pointer — and all three → Unmodeled (`no_ir_home`) at a position with no `Encoding` field, per §4.7; `$id`/`$schema`/`$vocabulary` → Unmodeled (`out_of_scope`, `$id` not honoured for resolution); `$dynamicRef` → the anchored type by compiler expansion, else verbatim Unmodeled with the reason it was irreducible; an inline `allOf` branch declaring more than the merge consumes → verbatim Unmodeled (`degraded_lowering`) per §4.8; a property redeclared across branches with a type, constraint keyword, default, description, examples, deprecation or `xml` the merge drops → the losing declaration's node verbatim Unmodeled (`degraded_lowering`) under `openapi:conflicting-redeclaration<pointer>` per §4.8, keyed by the losing declaration's pointer, with the `openapi/conflicting-redeclaration` diagnostic only where the two are unsatisfiable, an info `degraded-construct` naming a detail held differently, and nullability intersecting rather than dropping where the targets agree; a boolean `false` `allOf` branch → the composed Model closed, branch verbatim Unmodeled (`degraded_lowering`) per §4.8, a `true` branch a silent no-op; a shape applicator (`properties`/`patternProperties`/`additionalProperties`/`required`/`items`/`prefixItems`, and `format` where no type is declared) the lowered node has no field for → verbatim Unmodeled (`degraded_lowering`) per §4.8; a parameter schema's `xml` and its `readOnly`/`writeOnly` → Parameter.Unmodeled (`no_ir_home`: Parameter has no field for either); `patternProperties` → AdditionalProps.Patterns; `prefixItems` → Tuple, with any trailing `items` → Tuple.Unmodeled (`degraded_lowering` per §4.8: an open tuple has no IR combinator, so the fixed head is lowered and the tail kept beside it); `x-*` → namespaced Unmodeled (legal on every object — hence Unmodeled on every node), read at every object that admits one: an object lowering to a node with a map of its own keeps them unscoped there, and one lowering to no node of its own is keyed by the path from its carrier down to it — on the document, `openapi:info/x-*`, `openapi:info/contact/x-*`, `openapi:info/license/x-*`, `openapi:externalDocs/x-*`, `openapi:components/x-*`, `openapi:tags/<i>/x-*`, `openapi:tags/<i>/externalDocs/x-*`; on the service, `openapi:paths/x-*`; on each operation the path item's `openapi:pathItem/x-*` plus `openapi:responses/x-*` and `openapi:externalDocs/x-*`; on the HTTP binding, `openapi:callbacks/<name>/x-*`; on the content, `openapi:encoding/<part>/x-*` and `openapi:itemEncoding/x-*`, `<part>` and `<name>` alike escaped RFC 6901 style so a document-chosen name stays one segment (§12); on the schema's type, `openapi:xml/x-*`, `openapi:discriminator/x-*`, `openapi:externalDocs/x-*`; on the scheme, `openapi:flows/x-*` — since several such objects reach one map and an unscoped key would leave the survivor to lowering order (§12); a Link Object's own ride inside the verbatim `links` entry rather than taking a key beside it; `$ref`-adjacent sibling keywords (3.1) and ref-target annotations merge onto the referencing Property/Parameter with **use-site precedence**, applied uniformly (oagen's ad-hoc per-site patching is the counterexample), and at a position carrying no Property/Parameter — an `allOf`/`oneOf`/`anyOf` branch, `items`, a component — bind an alias hoisted at that position instead, per §4.3 — constraints excepted, since bounds conjoin rather than override: each position keeps the ones it declared and none is copied to a use site (§12.2); a oneOf/anyOf whose variants are all string consts normalizes to a closed `Enum` in a `pass/` normalization — not in the compiler — so per-variant `Docs` survive until the collapse is chosen; mutually-exclusive parameter groups (`x-mutually-exclusive-parameter-groups`) stay as namespaced Unmodeled entries, and their documented *promotion* (no dedicated node needed) is a pass that synthesizes one logical `Parameter` typed by a `Union` of variant models, bound via `HTTPParamBinding.ParamPath` per field; pagination only via injectable policy, marked Inferred | | ||
| | **OpenAPI 3.x** | components/schemas → registry (IDs from pointers); inline schemas hoisted with hints; `allOf` → Base/Mixins per §4.3; `oneOf`/`anyOf` → Union (Exclusive bit), null-variant → Nullable ref, a null-only branch set (every branch a bare `type: null`) → the same nullable `any` a bare `{type: null}` schema at that position lowers to, with the oneOf/anyOf itself kept verbatim Unmodeled (`degraded_lowering`) since no node built for it carries a branch set, co-declared with structural keywords → the composition distributed across the variants per §4.3, or — for the five shapes that cannot be distributed — structural body + verbatim union per §4.8 (branches that declare no shape at all are `validation_only` per §4.7); the same co-declared `oneOf`/`anyOf` beside a `$ref` at the same level, rather than a structural body, has nothing at that position to distribute the union across either, so it lowers to the alias over the `$ref` target with the union kept verbatim beside it (`degraded_lowering`), through the same keeper as the structural-body case; an `allOf` beside a `$ref` at the same level joins the alias's unhomed-keyword census the same way (`degraded_lowering`), since a `$ref` site elects no composition family for it to lose or win; competing keywords at one position — `const`/`enum`/`allOf`, elected in that order; `oneOf` beside `anyOf`, where oneOf wins; and a parameter's or header's `schema` beside its `content`, where content wins, since a media-type entry names both a schema and the media type serializing it and the IR models both — lower as the elected keyword with every passed-over one verbatim Unmodeled (`degraded_lowering`) at its own pointer per §4.8, and a `{X, null}` oneOf beside an anyOf stays a Union rather than collapsing to a nullable ref; `discriminator` → Discriminator (3.2 `defaultMapping` → Discriminator.Default), and a subtype the mapping names under several keys — an alias tag — takes the smallest in byte order as its DiscriminatorValue, since the field holds one and a mapping's key order is not a property of the document, with every key kept in the base's Mapping and the election reported (`degraded-construct`, info); `nullable`/type-arrays → Nullable; readOnly/writeOnly → Visibility and schema-level `default` → the referencing Property/Parameter's Default: both bind a *use* of the type rather than the type, so a declaration-site one is pushed down to referencing properties with use-site precedence and the declaration keeps its own copy verbatim (`no_ir_home` Unmodeled + diagnostic) — which is what a component nothing references would otherwise lose silently; `additionalProperties: false` → Additional=closed, `unevaluatedProperties: false` → closed_after_composition, `minProperties`/`maxProperties` → Model.Constraints (the property set's cardinality, as against Additional's openness); parameters → Params + HTTPBinding locations w/ style/explode, `allowEmptyValue` → Parameter.Unmodeled (`no_ir_home`: HTTPParamBinding holds its neighbours but not this one), a header parameter named Accept/Content-Type/Authorization lowered as declared + `reserved-header-name` warning (OpenAPI says such a definition SHALL be ignored; dropping declared content is an emitter's call, not a compiler's), 3.2 `in: querystring` → querystring location; a declared `style` there is refused by the parser, while `explode` or `allowReserved` (which it does not check; GitHub #408) lowers as declared + `invalid-location-keyword` warning at the keyword's own pointer; requestBody/responses all content types → Payload.Contents, `requestBody.required` → Payload.Required, always set since OpenAPI's own default makes an undeclared `required` mean false rather than unstated; 3.2 `itemSchema` → Content.Item and `itemEncoding` → Content.ItemEncoding — except beside a positional `prefixEncoding`, where both go to Content.Unmodeled (`no_ir_home`) because a single every-item encoding cannot state ordinals; an Encoding Object's `allowReserved` → Content.Unmodeled (`no_ir_home`) under `openapi:encoding/<part>/allowReserved` or `openapi:itemEncoding/allowReserved`, ir.PartEncoding holding `style` and `explode` beside it but no field for this one, and read before the entry's emptiness is judged so an entry declaring nothing else is not dropped with the empty PartEncoding it lowers to; per-status responses/default → Conditions + ranges, with the responses-map key as declared and then neutralized → Response.Name.Hint and ErrorCase.Name.Hint alike (`404`, `5_xx`, `default`), which records the spelling a range cannot state though only `default` survives neutralization unchanged; two keys resolving to one range — `4XX` beside `4xx` — are both kept and reported `openapi/duplicate-status-key`, since they reach the IR with one name and one condition; an error response lowers exactly as a success one — its `headers` → ErrorCase.Headers and every media type of its `content` → ErrorCase.Payload.Contents, neither degraded and neither kept under Unmodeled, and the payload's naming hint derived from the declaration pointer on both sides so that one `components/responses` entry mounted at a success and an error status interns one type whichever side reaches it first; response/encoding header `style` and `explode` → Property.Unmodeled (`no_ir_home`, ir.Property has neither field), and a `Content-Type` entry in either headers map lowered as declared + the same `reserved-header-name` warning the parameter position gets; webhooks → HTTPBinding.IsWebhook; callbacks → Callbacks; links → Response.Unmodeled and ErrorCase.Unmodeled alike (`no_ir_home`, promotable later): the two are lowerings of one Response Object, so a construct kept on only the success one makes a declaration survive or vanish on nothing but its status code; path-item `servers`, under `paths`, `webhooks` and a callback expression alike → Operation.Unmodeled (`no_ir_home`: §10 scopes servers by index list at service and channel, and an operation has no such list yet), with an operation's own `servers` — which OpenAPI says override the path item's — kept beside them under `openapi:operationServers`, the one key here not named for the keyword it holds, since two declarations at two pointers cannot share one map key without the survivor depending on lowering order; a path item's own `summary`/`description`, at the same three mounts → Operation.Unmodeled under `openapi:pathItemSummary`/`openapi:pathItemDescription` (`no_ir_home`) rather than merged into Docs: ir.Docs holds the operation's own pair and a path item's documents the path, so merging would need a precedence rule and would attach documentation the operation's author never wrote — an inference, which §6 places in policy rather than in a lowering; every operation a path item declares — the fixed method fields, 3.2 `query`, and 3.2 `additionalOperations` keyed by method — → an Operation apiece, mounted at its own pointer, with the `additionalOperations` key used verbatim as HTTPBinding.Method since OpenAPI reads a method name case-sensitively, and a key naming no method at all lowered as declared + `invalid-method-key` warning (the binding is unusable, but dropping the entry would lose every operation it declares); securitySchemes/security → Auth OR-of-ANDs, 3.2 device flow + `oauth2MetadataUrl` → Flows/OAuth2MetadataURL; servers+variables (3.2 named) → Servers; tags (3.2 parent/kind) → groups + TagDefs; info contact/license → Document; schema `example(s)` → Examples; `xml` object (incl. 3.2 nodeType) → XMLHints at type and property level; `not`/`if-then-else`/`dependentSchemas`/`dependentRequired`/`contains`/`propertyNames`/`unevaluated*` → verbatim Unmodeled per §4.7; `contentEncoding`/`contentMediaType`/`contentSchema` → `Encoding` on the scalar the position lowers to — the last as `Encoding.Schema`, a TypeRef to the decoded shape hoisted at its own pointer — and all three → Unmodeled (`no_ir_home`) at a position with no `Encoding` field, per §4.7; `format: password` — a redaction request about a value's handling rather than its shape, so no `PrimKind` can hold it and the shared primitive must not — → a Scalar of its own at every scalar position, carrying `Base` (the bare type's primitive) + `Sensitive` + `Encoding.Name` verbatim `"password"`, with `Property.Secret` set beside it at a property or a header (a parameter has no per-use field, so #578 owns that carrier); `$id`/`$schema`/`$vocabulary` → Unmodeled (`out_of_scope`, `$id` not honoured for resolution); `$dynamicRef` → the anchored type by compiler expansion, else verbatim Unmodeled with the reason it was irreducible; an inline `allOf` branch declaring more than the merge consumes → verbatim Unmodeled (`degraded_lowering`) per §4.8; a property redeclared across branches with a type, constraint keyword, default, description, examples, deprecation or `xml` the merge drops → the losing declaration's node verbatim Unmodeled (`degraded_lowering`) under `openapi:conflicting-redeclaration<pointer>` per §4.8, keyed by the losing declaration's pointer, with the `openapi/conflicting-redeclaration` diagnostic only where the two are unsatisfiable, an info `degraded-construct` naming a detail held differently, and nullability intersecting rather than dropping where the targets agree; a boolean `false` `allOf` branch → the composed Model closed, branch verbatim Unmodeled (`degraded_lowering`) per §4.8, a `true` branch a silent no-op; a shape applicator (`properties`/`patternProperties`/`additionalProperties`/`required`/`items`/`prefixItems`, and `format` where no type is declared) the lowered node has no field for → verbatim Unmodeled (`degraded_lowering`) per §4.8; a parameter schema's `xml` and its `readOnly`/`writeOnly` → Parameter.Unmodeled (`no_ir_home`: Parameter has no field for either); `patternProperties` → AdditionalProps.Patterns; `prefixItems` → Tuple, with any trailing `items` → Tuple.Unmodeled (`degraded_lowering` per §4.8: an open tuple has no IR combinator, so the fixed head is lowered and the tail kept beside it); `x-*` → namespaced Unmodeled (legal on every object — hence Unmodeled on every node), read at every object that admits one: an object lowering to a node with a map of its own keeps them unscoped there, and one lowering to no node of its own is keyed by the path from its carrier down to it — on the document, `openapi:info/x-*`, `openapi:info/contact/x-*`, `openapi:info/license/x-*`, `openapi:externalDocs/x-*`, `openapi:components/x-*`, `openapi:tags/<i>/x-*`, `openapi:tags/<i>/externalDocs/x-*`; on the service, `openapi:paths/x-*`; on each operation the path item's `openapi:pathItem/x-*` plus `openapi:responses/x-*` and `openapi:externalDocs/x-*`; on the HTTP binding, `openapi:callbacks/<name>/x-*`; on the content, `openapi:encoding/<part>/x-*` and `openapi:itemEncoding/x-*`, `<part>` and `<name>` alike escaped RFC 6901 style so a document-chosen name stays one segment (§12); on the schema's type, `openapi:xml/x-*`, `openapi:discriminator/x-*`, `openapi:externalDocs/x-*`; on the scheme, `openapi:flows/x-*` — since several such objects reach one map and an unscoped key would leave the survivor to lowering order (§12); a Link Object's own ride inside the verbatim `links` entry rather than taking a key beside it; `$ref`-adjacent sibling keywords (3.1) and ref-target annotations merge onto the referencing Property/Parameter with **use-site precedence**, applied uniformly (oagen's ad-hoc per-site patching is the counterexample), and at a position carrying no Property/Parameter — an `allOf`/`oneOf`/`anyOf` branch, `items`, a component — bind an alias hoisted at that position instead, per §4.3 — constraints excepted, since bounds conjoin rather than override: each position keeps the ones it declared and none is copied to a use site (§12.2); a oneOf/anyOf whose variants are all string consts normalizes to a closed `Enum` in a `pass/` normalization — not in the compiler — so per-variant `Docs` survive until the collapse is chosen; mutually-exclusive parameter groups (`x-mutually-exclusive-parameter-groups`) stay as namespaced Unmodeled entries, and their documented *promotion* (no dedicated node needed) is a pass that synthesizes one logical `Parameter` typed by a `Union` of variant models, bound via `HTTPParamBinding.ParamPath` per field; pagination only via injectable policy, marked Inferred | |
There was a problem hiding this comment.
"a Scalar of its own at every scalar position" overclaims. format: password beside enum or const lowers to an Enum/Literal, which has no Scalar to carry Sensitive: the format is kept verbatim in Unmodeled with an info degraded-construct and Sensitive stays false (probed with {type: string, format: password, enum: [a]} and {type: string, format: password, const: abc}). Property.Secret is still set for an inline property, but nothing marks items, additionalProperties, parameter or content positions.
Either state the exception here (and in the PR description's "no source fact is unread"), or carry the redaction on those nodes too.
There was a problem hiding this comment.
Two more places this row over-promises. The fixture's header comment says the same ("at every schema position").
- With
contentEncodingbeside the format,Encoding.Nameisbase64, notpassword:encodingNamelowers the content encoding, keeps the format verbatim underUnmodeledand reports an infodegraded-construct.Sensitiveis still set. Checked on the branch with{type: string, format: password, contentEncoding: base64, contentMediaType: text/plain}. This path is new for password and no test pins it. - The
enumandconstlimit is already tracked: openapi: an enum ignores its format although Enum.ValueType can carry it #607 (an enum ignores its format) and ir: a Literal carries no value type or format #668 (a Literal carries no value type or format). Citing them in the row would make the limit findable.
There was a problem hiding this comment.
Fixed in 291a221. The row now says "at a scalar position" and states both exceptions. Beside a differing contentEncoding, Encoding.Name is that encoding and the format is kept verbatim with a degraded-construct info. Beside enum/const, the position lowers to an Enum or Literal that keeps the format verbatim and is not Sensitive, citing #607 and #668. The fixture header says the same. TestRedactionFormat_BesideContentEncoding and TestRedactionFormat_BesideEnumOrConstIsNotSensitive pin both, the latter as a recorded limit rather than a goal. The PR body's "no source fact is unread" line is corrected as well.
| // A per-use carrier beside it (Property.Secret at a property or a header) is | ||
| // filled separately from the same redactionFormat predicate: one fact, one | ||
| // spelling, two homes a consumer reads as the same statement. | ||
| func hoistRedactionScalar(c lowering.Ctx, ts *compile.Types, anchors *AnchorIndex, depth int, s *oas3.Schema, st oas3.SchemaType, pointer jsontext.Pointer, hint string) (ir.TypeID, []ir.Diagnostic) { |
There was a problem hiding this comment.
hoistRedactionScalar is hoistFormatScalar plus common.Sensitive = true: same base ref, same scalarEncoding(..., format, ...), same schemaConstraints, same ir.Scalar literal. hoistFormatScalar already takes the base kind and the format verbatim, so this is a fourth near-identical hoister (byte, format, content, redaction).
Setting Sensitive inside hoistFormatScalar when format == redactionFormat, or giving the shared body a small option, would drop one copy and one schemaRecursion entry.
There was a problem hiding this comment.
Related to the duplication: nothing pins the one thing this hoister is documented to do. hoistByteScalar's doc says every hoister carries the position's value constraints itself (invariant 2; #147 was this bug for byte and unknown formats), and the content-vocabulary hoister has TestContentVocabulary_KeepsTheBoundsWrittenBesideIt.
I assigned cons = nil in hoistRedactionScalar and every test in ./compilers/openapi/..., ./internal/archtest and ./ir/... stayed green. The fixture writes no bound on a password schema, and neither do the unit tests. A bounds case at a component and at a non-property position (array items, a parameter), like the content-vocabulary one, would catch it. If the hoisters are merged as suggested above, one shared test covers both.
There was a problem hiding this comment.
Merged in 291a221. hoistFormatScalar sets Sensitive when the format is the redaction format, and hoistRedactionScalar and its recursion-allowlist entry are gone. On the bounds: the fixture now writes minLength on the Pw component, maxLength on array items and minLength on a header parameter, and requireLengthBounds asserts each one. Planting cons = nil for a Sensitive Scalar reddens the conformance case.
| // same schema position; the default comes from it too, falling back to its $ref | ||
| // target (§14). | ||
| // | ||
| // `format: password` reaches the type node as a Scalar carrying Sensitive, as |
There was a problem hiding this comment.
Two statements here do not hold for every parameter. "as any hoisting format does" is inaccurate, since byte and unknown-format Scalars do not set Sensitive. And "that node is the only carrier here" is only reachable directly when the parameter schema is the redaction schema itself: for $ref to an alias component (Chain: {$ref: Pw}) the parameter's type node is the alias, which has sensitive: false and reaches the Scalar only through Base. Worth saying so here and under #578.
There was a problem hiding this comment.
Rewritten in 291a221. It no longer says "as any hoisting format does". It now says that through a wrapper (an alias component, or an allOf over one $ref) the parameter's own node is not Sensitive, and only the node its Base chain reaches is. #578 is still named as the owner of a parameter-level field.
| // every ir field no committed spec drives to a non-zero value. Some of what it | ||
| // lists no spec can reach, such as a field only another source format writes. | ||
| // | ||
| // Not parallel: its -update subtests write the corpus, which |
There was a problem hiding this comment.
The ordering fix for TestConformance / TestConformance_TableNamesEveryCorpusSpec is a separate defect with its own repro and verification, unrelated to format: password. Repo guidance is one logical change per PR; this would fit its own PR (or an issue), and it keeps the squashed history about the redaction change only.
There was a problem hiding this comment.
The ordering fix also depends on declaration order: the new doc says the reader is "declared after" the writer. On this branch, deleting a golden and running go test ./compilers/openapi -run TestConformance -update -shuffle=on failed with the same "corpus specs and goldens disagree" in 5 of 6 runs; in default order it was green 4 of 4. The gate does not shuffle, so CI is unaffected, but a fix that does not rely on test order (one test that writes and then checks, or a reader that does not read goldens in update mode) would be sturdier. One more reason it belongs in its own PR.
| {"enum-numeric", assertEnumNumeric, []string{"enums-numeric"}}, | ||
| {"empty-enum", assertEmptyEnum, nil}, | ||
| {"scalar-format", assertScalarFormat, []string{"custom-scalars"}}, | ||
| {"secret-format", assertSecretFormat, []string{"custom-scalars"}}, |
There was a problem hiding this comment.
This row names custom-scalars as the matrix row the case witnesses, but the fixture only writes format: password, a known format that lowers to a redaction scalar rather than a custom scalar. scalar-format already witnesses that row. The matrix reader checks the key exists, not that the spec exercises it, so this over-claims coverage. Consider naming the row actually exercised or leaving the witness list empty with a reason.
There was a problem hiding this comment.
Right, it named a row the spec does not exercise. The witness list is empty now (291a221). The matrix has no redaction row, which is the case conformanceCase's doc allows an empty list for.
| // Keyed on the format rather than on the string type, so `format: password` | ||
| // on a non-string type and on a union variant states the same redaction: | ||
| // both are lossless today only by accident. | ||
| if format == redactionFormat { |
There was a problem hiding this comment.
Two things at this arm.
passwordwithwriteOnly,readOnlyordefault, the usual way to declare a password field, now follows the path in openapi: a $ref to a property's schema reports readOnly as kept on a node that lacks it #750. The position owns a node, so a$refaimed at the property's schema reportsdegraded-constructfor the keyword only when the reference lowers first. Repro: the openapi: a $ref to a property's schema reports readOnly as kept on a node that lacks it #750 document with the propertypreplaced by{type: string, format: password, writeOnly: true}. Onmain,go run ./cmd/morphic-harnessreports both ordersok. On this branch, and on this branch merged with currentmain, it reports bothorder-dependenton diagnostics, the same asformat: hex-tokenalready does onmain. It needs a$refto a property's schema by pointer and only an info diagnostic differs, but it is worth a line on openapi: a $ref to a property's schema reports readOnly as kept on a node that lacks it #750 and in the PR body so the next reader finds it. It is not fixable here without fixing openapi: a $ref to a property's schema reports readOnly as kept on a node that lacks it #750.- The comment above says the non-string and union-variant cases "are lossless today only by accident". After this change they are handled on purpose, so "today" describes a state the branch removes. "Keyed on the format so a non-string type and a union variant state the same redaction" says it without the history.
There was a problem hiding this comment.
- Reproduced: with
p: {type: string, format: password, writeOnly: true}in the openapi: a $ref to a property's schema reports readOnly as kept on a node that lacks it #750 document,morphic-harnessreportsorder-dependenton diagnostics on the branch andokonmain. It is now under accepted behaviour changes in the PR body and in a comment on openapi: a $ref to a property's schema reports readOnly as kept on a node that lacks it #750, noting thathex-tokenalready behaves this way. - Reworded in 291a221: "Keyed on the format rather than on the string type, so
format: passwordon a non-string type and on a union variant states the same redaction." The test doc that said "lossless only by accident" lost that clause too.
|
Merge-time notes that are not tied to a line.
|
Property.Secret was derived from the schema literal alone: the position's own format plus one $ref hop. A use reaching the Sensitive node through a nullable oneOf or anyOf, or an allOf wrapping a single $ref, stayed not secret even though its type led straight to the node. MarkSecretUses now runs once lowering is done and marks every property and header whose type reaches a Sensitive node directly, through a nullable ref or along a Base chain. It runs last because a $ref names a component's ID before that component is lowered, so no node is there to read any earlier. hoistRedactionScalar was hoistFormatScalar plus Sensitive; the redaction format now sets Sensitive inside hoistFormatScalar and the copy is gone. The secret-format fixture gains the wrapper spellings, an alias declared after its use, a nullable header, and length bounds at a component, array items and a parameter, so dropping a hoisted Scalar's constraints reddens it. Unit tests pin the contentEncoding path, where Encoding.Name is the content encoding, and the enum/const limit tracked by #607 and #668, which the ir-design row now states. The fixture no longer claims the custom-scalars matrix row, which it does not exercise. The allOf redeclaration comments now point at #782, which tracks the order-dependent drop of a format-hoisting redeclaration, instead of #446.
This undoes ecbeb0b, which made TestConformance and TestConformance_TableNamesEveryCorpusSpec sequential so that the golden writer finished before the corpus reader ran under -update. That race is unrelated to format: password, and the fix relies on declaration order: with -shuffle=on the reader can still run first. It belongs in a change of its own that does not depend on test order.
Summary
formatTablemappedstring/passwordtoir.PrimString— the same kindtype: stringalone selects — soscalarTypeIDreturned the shared primitive and nothing recorded the format. The one position that worked was an inline property, whereFillPropertyDetailread the literal; a$ref, array items,additionalProperties,patternProperties,prefixItems, parameters, content schemas and headers all lost it silently.grep -c passwordon the issue's output was0.A position whose format is
passwordnow hoists aScalarof its own carryingSensitive(whole-type redaction) andEncoding.Name"password"(the source token, verbatim), exactly asbyteand an unknown format already do — a control run withformat: hex-tokenin the same positions shows the pre-existing target shape.Property.Secretis set on every property and header whose type reaches that node: directly, through a nullableoneOf/anyOf, or along aBasechain (anallOfover one$ref, an alias component).MarkSecretUsesdoes this once lowering is done, because a$refnames a component before that component is lowered.grep -c passwordon the issue's document:0 → 4, with no diagnostic at any severity (--fail-on warningexits 0).Decisions
Sensitive+Encoding.Nameon a hoistedScalaris the same shapebyteand unknown formats produce.Property.Secretis set beside it at a property or a header.passwordkeys on the format, not onstring, soformat: passwordon a non-string type or a union variant states the same redaction instead of surviving only as a verbatim format string.enum/const, the position lowers to an Enum or a Literal, which keeps the format verbatim inUnmodeledwith an infodegraded-constructand is notSensitive(openapi: an enum ignores its format although Enum.ValueType can carry it #607, ir: a Literal carries no value type or format #668). Beside a differingcontentEncoding,Encoding.Nameis the content encoding and the format is kept verbatim with the same info, whileSensitivestill holds.ir.Parameterhas none), so the parameter's type node is the carrier and#578owns that field; the position says so in code.IRVersionchange — comments and the normative OpenAPI row only.Accepted behaviour changes
docs/xml/deprecation/examplesland on the node whiledefaultand the value constraints stay on the carrier — measured identical to thebyteand unknown-format controls.allOfredeclaration ofpasswordover a plain{type: string}(and the reverse) is now a silent drop of the losing declaration, kept verbatim underopenapi:conflicting-redeclaration, with no new error or warning. Measured in both declaration orders. The same order-dependent drop already happens onmainfor an unknown format (hex-token); openapi: an allOf redeclaration of a property with an unknown format is resolved by branch order #782 tracks it, and the code comments point there.$refpoints at a property's schema by pointer, a password property that writeswriteOnly,readOnlyordefaultnow follows openapi: a $ref to a property's schema reports readOnly as kept on a node that lacks it #750: an infodegraded-constructis reported only when the reference lowers first, somorphic-harnessreports the documentorder-dependenton diagnostics. That happens because the position now owns a node.format: hex-tokenalready behaves this way onmain, and it can't be fixed here without fixing openapi: a $ref to a property's schema reports readOnly as kept on a node that lacks it #750.Test plan
secret-format.yaml+ golden, asserting where the fact lands at every position: a named component, an inline property, a property via$ref, array items,additionalProperties,patternProperties,prefixItems, a header parameter schema and a query parameter via$ref, request and response content schemas, and a response header — plus the guard that a plain{type: string}still targets the sharedt/prim/stringand is not sensitive.TestFormatTable_EveryRowDistinguishesTheBareTypepins the invariant the row broke (a pairing may only map to a kind its bare type does not); re-inserting the row reddens it, and removing it again leaves the tree byte-identical.compose_test.goreworked onto a format-free fixture, with theSecretOR-fold kept as its own focused case in both declaration orders.assertScalarFormatextended, andunwitnessed.golden.txtlosesTypeCommon.Sensitiveas the field becomes witnessed.go run ./cmd/morphic-harness testdata/conformance/openapi/secret-format.yaml→ok;go test ./internal/harnessgreen;make gategreen on the branch (all statements covered,golangci-lint0 issues).oneOf/anyOf,allOfover one$ref, an alias declared after its first use), a nullable response header, and length bounds at a component, at array items and at a parameter. Removing theMarkSecretUsescall reddens it, and so does dropping a Sensitive Scalar's constraints.TestRedactionFormat_BesideContentEncodingandTestRedactionFormat_BesideEnumOrConstIsNotSensitivepin thecontentEncodingpath and theenum/constlimit. The case no longer claims thecustom-scalarsmatrix row, which it does not exercise.Closes #579.