Skip to content

fix(compilers/openapi): keep format: password at every position - #592

Open
fuad-daoud wants to merge 7 commits into
mainfrom
relevo/secret-format
Open

fuad-daoud wants to merge 7 commits into
mainfrom
relevo/secret-format

Conversation

@fuad-daoud

@fuad-daoud fuad-daoud commented Sep 29, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

formatTable mapped string/password to ir.PrimString — the same kind type: string alone selects — so scalarTypeID returned the shared primitive and nothing recorded the format. The one position that worked was an inline property, where FillPropertyDetail read the literal; a $ref, array items, additionalProperties, patternProperties, prefixItems, parameters, content schemas and headers all lost it silently. grep -c password on the issue's output was 0.

A position whose format is password now hoists a Scalar of its own carrying Sensitive (whole-type redaction) and Encoding.Name "password" (the source token, verbatim), exactly as byte and an unknown format already do — a control run with format: hex-token in the same positions shows the pre-existing target shape. Property.Secret is set on every property and header whose type reaches that node: directly, through a nullable oneOf/anyOf, or along a Base chain (an allOf over one $ref, an alias component). MarkSecretUses does this once lowering is done, because a $ref names a component before that component is lowered.

grep -c password on the issue's document: 0 → 4, with no diagnostic at any severity (--fail-on warning exits 0).

Decisions

  • Carrier: the node, plus the per-use field where one exists. The primitive is interned once per kind and shared by every declaration of that type, so a per-declaration fact cannot live on it; Sensitive + Encoding.Name on a hoisted Scalar is the same shape byte and unknown formats produce. Property.Secret is set beside it at a property or a header.
  • password keys on the format, not on string, so format: password on a non-string type or a union variant states the same redaction instead of surviving only as a verbatim format string.
  • No new diagnostic. Every scalar position now reaches a field. Two exceptions are recorded rather than fixed here. Beside enum/const, the position lowers to an Enum or a Literal, which keeps the format verbatim in Unmodeled with an info degraded-construct and is not Sensitive (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 differing contentEncoding, Encoding.Name is the content encoding and the format is kept verbatim with the same info, while Sensitive still holds.
  • Parameters have no per-use redaction field (ir.Parameter has none), so the parameter's type node is the carrier and #578 owns that field; the position says so in code.
  • No IR schema or IRVersion change — comments and the normative OpenAPI row only.

Accepted behaviour changes

Test plan

  • New conformance case 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 shared t/prim/string and is not sensitive.
  • TestFormatTable_EveryRowDistinguishesTheBareType pins 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.go reworked onto a format-free fixture, with the Secret OR-fold kept as its own focused case in both declaration orders.
  • assertScalarFormat extended, and unwitnessed.golden.txt loses TypeCommon.Sensitive as the field becomes witnessed.
  • go run ./cmd/morphic-harness testdata/conformance/openapi/secret-format.yaml → ok; go test ./internal/harness green; make gate green on the branch (all statements covered, golangci-lint 0 issues).
  • The fixture also covers the wrapper spellings (nullable oneOf/anyOf, allOf over 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 the MarkSecretUses call reddens it, and so does dropping a Sensitive Scalar's constraints.
  • TestRedactionFormat_BesideContentEncoding and TestRedactionFormat_BesideEnumOrConstIsNotSensitive pin the contentEncoding path and the enum/const limit. The case no longer claims the custom-scalars matrix row, which it does not exercise.
  • The corpus-test ordering change that an earlier commit carried is reverted here; the race it addressed is compilers/openapi: corpus reader races the golden writer under -update #817.
  • A final whitespace commit ends the new fixture with a newline (the convention every other fixture follows) and regenerates the one-line source hash in its golden.

Closes #579.

@fuad-daoud fuad-daoud self-assigned this Sep 29, 2026
@fuad-daoud fuad-daoud changed the title fix(compilers/openapi): keep format: password at every schema position (#579) fix(compilers/openapi): keep format: password at every schema position Sep 30, 2026
@fuad-daoud fuad-daoud changed the title fix(compilers/openapi): keep format: password at every schema position fix(compilers/openapi): keep format: password at every position Sep 30, 2026
#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.
@fuad-daoud

Copy link
Copy Markdown
Collaborator Author

Rebased onto main (07576ab) at 2026-09-30. The first CI run failed with a golden mismatch on the new fixture: #551 landed after this branch was cut and makes a shared primitive carry provenance.source: -1 (NoSource) where the golden said 0. The new golden's one primitive line was regenerated; go test ./... is green on the rebased branch and the gate is re-running. No production code changed in this rebase.

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}`

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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, examples and secret.
  • 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 under openapi:conflicting-redeclaration<ptr>.
  • this branch, password declaration first: type is the hoisted t/anon/.../allOf/0/properties/token and 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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Three corrections to this comment.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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 ||

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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 the anyOf form: type is Pw (nullable), secret: false.
  • {allOf: [{$ref: Pw}], description: ...} (the usual way to annotate a $ref): anonymous Model with Base: Pw, sensitive: false, secret: false.
  • {$ref: Pw}: secret: true.
  • An alias component Chain: {$ref: Pw} is sensitive: false as well, so a parameter or array item typed Chain has 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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The doc on TestAllOf_RedeclarationOrsSecret says this is the interplay #446 owns. It is not; see the reply on merge.go. The mechanism is tracked in #782. Once that is fixed this fixture can write the format again, and the new case should assert the folded fields as well as Secret.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

The doc on TestAllOf_RedeclarationOrsSecret now cites #782 instead of #446 and says the case should assert the folded fields once that is fixed. The comment in TestAllOf_ReconcileAccumulatesRicherDetailWhateverTheOrder points there too (291a221).

// 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 {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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, XML and Examples are 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).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Agreed with the retraction: the annotations stay on the node the position owns, as for every other hoisting position. No change here.

Comment thread docs/ir-design.md Outdated
| 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 |

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

"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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Two more places this row over-promises. The fixture's header comment says the same ("at every schema position").

  • With contentEncoding beside the format, Encoding.Name is base64, not password: encodingName lowers the content encoding, keeps the format verbatim under Unmodeled and reports an info degraded-construct. Sensitive is 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 enum and const limit 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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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.

Comment thread compilers/openapi/conformance_test.go Outdated
// 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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Agreed, and the -shuffle=on result settles it. Reverted on this branch in c2bef74, so both tests are t.Parallel() again exactly as on main. The race and the order-independent fixes you suggested are in #817, so it can land in a PR of its own.

Comment thread compilers/openapi/conformance_test.go Outdated
{"enum-numeric", assertEnumNumeric, []string{"enums-numeric"}},
{"empty-enum", assertEmptyEnum, nil},
{"scalar-format", assertScalarFormat, []string{"custom-scalars"}},
{"secret-format", assertSecretFormat, []string{"custom-scalars"}},

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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 {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Two things at this arm.

  1. password with writeOnly, readOnly or default, 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 $ref aimed at the property's schema reports degraded-construct for 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 property p replaced by {type: string, format: password, writeOnly: true}. On main, go run ./cmd/morphic-harness reports both orders ok. On this branch, and on this branch merged with current main, it reports both order-dependent on diagnostics, the same as format: hex-token already does on main. It needs a $ref to 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.
  2. 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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

  1. 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-harness reports order-dependent on diagnostics on the branch and ok on main. 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 that hex-token already behaves this way.
  2. Reworded in 291a221: "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." The test doc that said "lossless only by accident" lost that clause too.

@OmarAlJarrah

Copy link
Copy Markdown
Member

Merge-time notes that are not tied to a line.

  • Merge commit 713128d carries a co-author trailer naming an AI assistant and a session-link trailer. The repo asks for no LLM or session artifacts in commit messages, and the squash default here is COMMIT_MESSAGES, which pastes every branch commit body into the squashed commit. The branch cannot be rewritten, so set the squash body explicitly when merging.
  • Two branch commit subjects are over the 72-character cap: d7650c9 (77) and ecbeb0b (80). The PR title is 70 characters with (#592) appended, so the squashed subject is within the cap.

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.
@fuad-daoud

Copy link
Copy Markdown
Collaborator Author

Thanks for the merge-time notes. The squash body will be set explicitly at merge, so neither the merge commit's trailers nor the two long branch subjects (d7650c9, ecbeb0b) reach main; the PR title is the squashed subject and stays within the cap.

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.

openapi: format: password is erased everywhere but an inline property

2 participants